diff --git a/go.mod b/go.mod index f56c9fd7..168cf25b 100644 --- a/go.mod +++ b/go.mod @@ -88,6 +88,7 @@ require ( github.com/go-ruby-mustache/mustache v0.0.0-20260701123847-26d5e451677a github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 + github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 diff --git a/go.sum b/go.sum index a357f88a..dc2e77ba 100644 --- a/go.sum +++ b/go.sum @@ -338,6 +338,8 @@ github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d h1:tlOKd49moGe github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d/go.mod h1:ppWK2CmOURx2o9vRsiGVeIZHI/AjCSq0RX74ssEZG8g= github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG977zAq2WQGos1HgRLj/jHJqkIiLL6E= github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= +github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e h1:OJa/XKKm+bxdCoM3UKpmsgnk8hl3bFz25jEbZCvd08I= +github.com/go-ruby-net-ftp/net-ftp v0.0.0-20260630142044-917edc09066e/go.mod h1:1H5yOndya/hONJxNjd0oAbRyCAyurxcbze0FXlF2ExA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..d5ff61c3 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -425,6 +425,7 @@ func (vm *VM) bootstrap() { vm.registerSocket() // TCPSocket/TCPServer (net) + OpenSSL::SSL::SSLSocket (crypto/tls); after registerOpenSSL (upgrades its SSL shell) vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError vm.registerNetHTTPTransport() // real Net::HTTP over the socket transport; after registerNetHTTP + registerSocket + vm.registerNetFTP() // real Net::FTP over the socket transport; after registerNetHTTP (Net module) + registerSocket/registerOpenSSL vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerDateErrors() // Date::Error < ArgumentError (Date class itself registered early); needs ArgumentError diff --git a/internal/vm/net_ftp.go b/internal/vm/net_ftp.go new file mode 100644 index 00000000..a68c9877 --- /dev/null +++ b/internal/vm/net_ftp.go @@ -0,0 +1,754 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "bytes" + "net" + "os" + "strconv" + "strings" + stdtime "time" + + gotime "github.com/go-composites/time/src" + netftp "github.com/go-ruby-net-ftp/net-ftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// registerNetFTP installs Net::FTP (require "net/ftp"): a real FTP client that +// drives control and data sockets (Go net) on top of the interpreter-independent +// FTP codec github.com/go-ruby-net-ftp/net-ftp. The codec owns every observable +// byte (command construction, reply classification, PASV/EPSV extraction, MLSx +// parsing); this binding owns the sockets and maps the Ruby surface — connect / +// login, the directory and file commands, the LIST/NLST/MLSD/MLST listings, the +// retr*/stor* transfer engines, and the get*/put* file helpers — onto it. +// +// It runs after registerNetHTTP (which creates the Net module) and after +// registerSocket / registerOpenSSL (the transport it dials through), so the Net +// module and the exception root already exist. +func (vm *VM) registerNetFTP() { + netMod := vm.consts["Net"].(*RClass) + std := vm.consts["StandardError"].(*RClass) + + vm.registerFTPErrors(netMod, std) + + ftp := newClass("Net::FTP", vm.cObject) + netMod.consts["FTP"] = ftp + vm.consts["Net::FTP"] = ftp + + entry := newClass("Net::FTP::MLSxEntry", vm.cObject) + ftp.consts["MLSxEntry"] = entry + vm.consts["Net::FTP::MLSxEntry"] = entry + vm.registerFTPMLSxEntry(entry) + + vm.registerFTPClassMethods(ftp) + vm.registerFTPMethods(ftp) +} + +// registerFTPErrors installs the Net::FTP exception family (FTPError < +// StandardError; FTPReplyError / FTPTempError / FTPPermError / FTPProtoError / +// FTPConnectionError < FTPError), publishing each qualified name so raise() (and +// the codec's ClassName) resolves the real class a `rescue Net::FTPPermError` +// catches. +func (vm *VM) registerFTPErrors(netMod, std *RClass) { + reg := func(simple string, super *RClass) *RClass { + qualified := "Net::" + simple + c := newClass(qualified, super) + netMod.consts[simple] = c + vm.consts[qualified] = c + return c + } + base := reg("FTPError", std) + reg("FTPReplyError", base) + reg("FTPTempError", base) + reg("FTPPermError", base) + reg("FTPProtoError", base) + reg("FTPConnectionError", base) +} + +// --- class methods ---------------------------------------------------------- + +// registerFTPClassMethods installs Net::FTP.new and Net::FTP.open. new(host=nil, +// options) connects (and logs in when a username is supplied); open is new plus +// the ensured-close block form. +func (vm *VM) registerFTPClassMethods(ftp *RClass) { + newFn := func(vm *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + f := &ftpObj{cls: ftp, binary: true, passive: true} + vm.ftpInit(f, args) + return f + } + ftp.smethods["new"] = &Method{name: "new", owner: ftp, native: newFn} + + ftp.smethods["open"] = &Method{name: "open", owner: ftp, native: func(vm *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + f := &ftpObj{cls: ftp, binary: true, passive: true} + vm.ftpInit(f, args) + if blk == nil { + return f + } + // Net::FTP.open(...) { |ftp| ... } yields the connected client and closes it + // afterwards, even if the block raises (the deferred close runs on unwind). + return vm.ftpWithClose(f, blk) + }} +} + +// ftpWithClose yields f to blk and closes the control connection afterwards, +// including on an exception unwind (the deferred close runs before the panic +// propagates). +func (vm *VM) ftpWithClose(f *ftpObj, blk *Proc) (res object.Value) { + defer f.doClose() + return vm.callBlock(blk, []object.Value{f}) +} + +// ftpInit applies the constructor arguments: an optional host (connect), an +// options Hash (port / passive / username / password / account / use_pasv_ip) or +// the legacy positional user / passwd / acct, and a login when a username is +// present. +func (vm *VM) ftpInit(f *ftpObj, args []object.Value) { + if len(args) == 0 || object.IsNil(args[0]) { + return + } + host := strArg(args[0]) + port := 21 + user, passwd, acct := "", "", "" + userGiven, passwdGiven := false, false + + if len(args) >= 2 { + if h, ok := args[1].(*object.Hash); ok { + port, user, userGiven, passwd, passwdGiven, acct = vm.ftpApplyOpts(f, h, port) + } + } + + vm.ftpConnect(f, host, port) + if userGiven { + vm.ftpLogin(f, user, passwd, passwdGiven, acct) + } +} + +// ftpApplyOpts reads the options Hash into f (passive / use_pasv_ip) and returns +// the connection port plus the login credentials it carries. +func (vm *VM) ftpApplyOpts(f *ftpObj, h *object.Hash, port int) (int, string, bool, string, bool, string) { + user, passwd, acct := "", "", "" + userGiven, passwdGiven := false, false + if v, ok := ftpOptHash(h, "port"); ok { + if n, ok := v.(object.Integer); ok { + port = int(n) + } + } + if v, ok := ftpOptHash(h, "passive"); ok { + f.passive = v.Truthy() + } + if v, ok := ftpOptHash(h, "use_pasv_ip"); ok { + f.usePasvIP = v.Truthy() + } + if v, ok := ftpOptHash(h, "username", "user"); ok { + user, userGiven = strArg(v), true + } + if v, ok := ftpOptHash(h, "password", "passwd"); ok { + passwd, passwdGiven = strArg(v), true + } + if v, ok := ftpOptHash(h, "account", "acct"); ok { + acct = strArg(v) + } + return port, user, userGiven, passwd, passwdGiven, acct +} + +// ftpOptHash looks a key up in an options Hash by any of its symbol or string +// spellings. +func ftpOptHash(h *object.Hash, names ...string) (object.Value, bool) { + for _, n := range names { + if v, ok := h.Get(object.Symbol(n)); ok { + return v, true + } + if v, ok := h.Get(object.NewString(n)); ok { + return v, true + } + } + return nil, false +} + +// ftpConnect dials the control connection, reads the server greeting (recorded +// as welcome), and selects EPSV/EPRT when the connection is IPv6 +// (Net::FTP#connect). +func (vm *VM) ftpConnect(f *ftpObj, host string, port int) { + conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + ftpConnErr(err) + } + f.conn = conn + f.r = bufio.NewReader(conn) + f.closed = false + f.epsv = ftpIsIPv6(conn) + resp := f.getResp() + f.welcome = resp +} + +// ftpLogin runs the USER → PASS → ACCT ladder (Net::FTP#login), substituting the +// anonymous password when logging in as "anonymous" with none supplied and +// raising FTPReplyError when a required credential is missing. +func (vm *VM) ftpLogin(f *ftpObj, user, passwd string, passwdGiven bool, acct string) { + if !passwdGiven { + if pw, ok := netftp.AnonymousLogin(user, false); ok { + passwd, passwdGiven = pw, true + } + } + resp := f.sendCmd(netftp.UserCommand(user)) + step, err := netftp.LoginNext(resp, false) + if err != nil { + ftpRaise(err) + } + if step == netftp.LoginSendPass { + if !passwdGiven { + raise("Net::FTPReplyError", "%s", resp) + } + resp = f.sendCmd(netftp.PassCommand(passwd)) + step, err = netftp.LoginNext(resp, true) + if err != nil { + ftpRaise(err) + } + } + if step == netftp.LoginSendAcct { + if acct == "" { + raise("Net::FTPReplyError", "%s", resp) + } + resp = f.sendCmd(netftp.AcctCommand(acct)) + if !strings.HasPrefix(resp, "2") { + raise("Net::FTPReplyError", "%s", resp) + } + } +} + +// --- instance methods ------------------------------------------------------- + +// registerFTPMethods installs the Net::FTP instance surface: the connect/login +// entry points, the mode accessors, the directory and file commands, the listing +// commands, the transfer engines, and the get/put file helpers. +func (vm *VM) registerFTPMethods(ftp *RClass) { + self := func(v object.Value) *ftpObj { return v.(*ftpObj) } + d := func(name string, fn NativeFn) { ftp.define(name, fn) } + + d("connect", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 2, "connect") + port := 21 + if len(a) >= 2 { + port = int(a[1].(object.Integer)) + } + vm.ftpConnect(self(v), strArg(a[0]), port) + return object.NilV + }) + d("login", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + user := ftpOptString(a, 0, "anonymous") + passwd, passwdGiven := "", false + if len(a) >= 2 && !object.IsNil(a[1]) { + passwd, passwdGiven = strArg(a[1]), true + } + vm.ftpLogin(self(v), user, passwd, passwdGiven, ftpOptString(a, 2, "")) + return object.NilV + }) + + // --- mode accessors --- + d("passive", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).passive) + }) + d("passive=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).passive = a[0].Truthy() + return a[0] + }) + d("binary", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).binary) + }) + d("binary=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).binary = a[0].Truthy() + return a[0] + }) + d("use_pasv_ip", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(self(v).usePasvIP) + }) + d("use_pasv_ip=", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + self(v).usePasvIP = a[0].Truthy() + return a[0] + }) + d("welcome", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).welcome) + }) + d("last_response", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).lastResponse) + }) + d("last_response_code", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(netftp.ReplyCode(self(v).lastResponse)) + }) + d("lastresp", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(netftp.ReplyCode(self(v).lastResponse)) + }) + + // --- generic command senders --- + d("sendcmd", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "sendcmd") + return object.NewString(self(v).sendCmd(strArg(a[0]))) + }) + d("voidcmd", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "voidcmd") + self(v).voidCmd(strArg(a[0])) + return object.NilV + }) + d("noop", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).voidCmd(netftp.NoopCommand) + return object.NilV + }) + + // --- directory commands --- + d("pwd", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpPwd(self(v))) + }) + d("getdir", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpPwd(self(v))) + }) + d("chdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "chdir") + vm.ftpChdir(self(v), strArg(a[0])) + return object.NilV + }) + d("mkdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mkdir") + f := self(v) + name, err := netftp.Parse257(f.sendCmd(netftp.MkdCommand(strArg(a[0])))) + if err != nil { + ftpRaise(err) + } + return object.NewString(name) + }) + d("rmdir", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "rmdir") + self(v).voidCmd(netftp.RmdCommand(strArg(a[0]))) + return object.NilV + }) + d("delete", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "delete") + f := self(v) + if err := netftp.CheckDelete(f.sendCmd(netftp.DeleCommand(strArg(a[0])))); err != nil { + ftpRaise(err) + } + return object.NilV + }) + d("rename", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 2, "rename") + f := self(v) + if err := netftp.CheckRenameFrom(f.sendCmd(netftp.RnfrCommand(strArg(a[0])))); err != nil { + ftpRaise(err) + } + f.voidCmd(netftp.RntoCommand(strArg(a[1]))) + return object.NilV + }) + + // --- metadata commands --- + d("size", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "size") + f := self(v) + f.setType(true) + n, err := netftp.SizeResult(f.sendCmd(netftp.SizeCommand(strArg(a[0])))) + if err != nil { + ftpRaise(err) + } + return object.IntValue(int64(n)) + }) + d("system", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + f := self(v) + s, err := netftp.SystResult(f.sendCmd(netftp.SystCommand)) + if err != nil { + ftpRaise(err) + } + return object.NewString(s) + }) + d("status", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).sendCmd(netftp.StatCommand(ftpOptString(a, 0, "")))) + }) + d("mdtm", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mdtm") + f := self(v) + raw, ok := netftp.MdtmResult(f.sendCmd(netftp.MdtmCommand(strArg(a[0])))) + if !ok { + raise("Net::FTPReplyError", "%s", f.lastResponse) + } + return object.NewString(raw) + }) + d("mtime", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "mtime") + return vm.ftpMtime(self(v), strArg(a[0])) + }) + d("help", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return object.NewString(self(v).sendCmd(netftp.HelpCommand(ftpOptString(a, 0, "")))) + }) + d("site", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 1, "site") + self(v).voidCmd(netftp.SiteCommand(strArg(a[0]))) + return object.NilV + }) + + // --- listing commands --- + d("nlst", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + f := self(v) + var out []object.Value + f.retrlines(netftp.NlstCommand(ftpOptString(a, 0, "")), func(line string) { + out = append(out, object.NewString(line)) + }) + return object.NewArrayFromSlice(out) + }) + list := func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + f := self(v) + args := make([]string, len(a)) + for i, x := range a { + args[i] = strArg(x) + } + var out []object.Value + f.retrlines(netftp.ListCommand(args...), func(line string) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + } + out = append(out, object.NewString(line)) + }) + return object.NewArrayFromSlice(out) + } + d("list", list) + d("ls", list) + d("dir", list) + d("mlsd", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + f := self(v) + var out []object.Value + var perr error + f.retrlines(netftp.MlsdCommand(ftpOptString(a, 0, "")), func(line string) { + if perr != nil || line == "" { + return + } + e, err := netftp.ParseMLSxEntry(line) + if err != nil { + perr = err + return + } + entry := vm.ftpEntry(e) + if blk != nil { + vm.callBlock(blk, []object.Value{entry}) + } + out = append(out, entry) + }) + if perr != nil { + ftpRaise(perr) + } + return object.NewArrayFromSlice(out) + }) + d("mlst", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + return vm.ftpMlst(self(v), ftpOptString(a, 0, "")) + }) + + // --- transfer engines --- + d("retrlines", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 1, "retrlines") + self(v).retrlines(strArg(a[0]), func(line string) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + } + }) + return object.NilV + }) + d("retrbinary", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 2, "retrbinary") + self(v).retrbinary(strArg(a[0]), ftpBlocksizeArg(a, 1), func(chunk []byte) { + if blk != nil { + vm.callBlock(blk, []object.Value{object.NewStringBytesEnc(chunk, "ASCII-8BIT")}) + } + }) + return object.NilV + }) + d("storlines", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 2, "storlines") + self(v).storlines(strArg(a[0]), string(ftpBytes(a[1]))) + return object.NilV + }) + d("storbinary", func(_ *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 2, 3, "storbinary") + self(v).storbinary(strArg(a[0]), ftpBytes(a[1]), ftpBlocksizeArg(a, 2)) + return object.NilV + }) + + // --- file helpers --- + d("getbinaryfile", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 3, "getbinaryfile") + return vm.ftpGetBinary(self(v), a, blk) + }) + d("gettextfile", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 2, "gettextfile") + return vm.ftpGetText(self(v), a, blk) + }) + d("get", func(vm *VM, v object.Value, a []object.Value, blk *Proc) object.Value { + ftpArity(a, 1, 3, "get") + f := self(v) + if f.binary { + return vm.ftpGetBinary(f, a, blk) + } + return vm.ftpGetText(f, a, blk) + }) + d("putbinaryfile", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 3, "putbinaryfile") + return vm.ftpPutBinary(self(v), a) + }) + d("puttextfile", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 2, "puttextfile") + return vm.ftpPutText(self(v), a) + }) + d("put", func(vm *VM, v object.Value, a []object.Value, _ *Proc) object.Value { + ftpArity(a, 1, 3, "put") + f := self(v) + if f.binary { + return vm.ftpPutBinary(f, a) + } + return vm.ftpPutText(f, a) + }) + + // --- teardown --- + d("abort", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(vm.ftpAbort(self(v))) + }) + d("quit", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).voidCmd(netftp.QuitCommand) + return object.NilV + }) + d("close", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + self(v).doClose() + return object.NilV + }) + d("closed?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + f := self(v) + return object.Bool(f.closed || f.conn == nil) + }) +} + +// ftpPwd sends PWD and decodes the quoted pathname (Net::FTP#pwd). +func (vm *VM) ftpPwd(f *ftpObj) string { + name, err := netftp.Parse257(f.sendCmd(netftp.PwdCommand)) + if err != nil { + ftpRaise(err) + } + return name +} + +// ftpChdir changes directory (Net::FTP#chdir): ".." tries CDUP first and falls +// back to CWD only on a 500 reply; every other directory goes straight to CWD. +func (vm *VM) ftpChdir(f *ftpObj, dir string) { + if dir == ".." { + // CDUP's 500 ("command not understood") is the one reply that falls through + // to CWD "..", so its reply is read raw (bypassing getResp's raise) to + // inspect the code before deciding. + f.writeLine(netftp.CdupCommand) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + if strings.HasPrefix(resp, "2") { + return + } + if netftp.ReplyCode(resp) != "500" { + if _, cerr := netftp.ClassifyReply(resp); cerr != nil { + ftpRaise(cerr) + } + raise("Net::FTPReplyError", "%s", resp) + } + } + f.voidCmd(netftp.CwdCommand(dir)) +} + +// ftpMtime sends MDTM and parses the "YYYYMMDDhhmmss" reply into a UTC Time +// (Net::FTP#mtime). +func (vm *VM) ftpMtime(f *ftpObj, file string) object.Value { + raw, ok := netftp.MdtmResult(f.sendCmd(netftp.MdtmCommand(file))) + if !ok { + raise("Net::FTPReplyError", "%s", f.lastResponse) + } + t, err := stdtime.Parse("20060102150405", raw) + if err != nil { + raise("Net::FTPProtoError", "invalid time-val: %s", raw) + } + return &Time{t: gotime.FromUnix(t.Unix()).UTC()} +} + +// ftpMlst sends MLST, requires the 250 multiline reply, and parses its middle +// line into a Net::FTP::MLSxEntry (Net::FTP#mlst). +func (vm *VM) ftpMlst(f *ftpObj, pathname string) object.Value { + resp := f.sendCmd(netftp.MlstCommand(pathname)) + if !strings.HasPrefix(resp, "250") { + raise("Net::FTPReplyError", "%s", resp) + } + // The assembled reply always ends in a newline, so a Split yields at least two + // elements; a single-line 250 leaves an empty entry line, which ParseMLSxEntry + // rejects as FTPProtoError below (no pathname). + lines := strings.Split(resp, "\n") + e, err := netftp.ParseMLSxEntry(strings.TrimLeft(lines[1], " ")) + if err != nil { + ftpRaise(err) + } + return vm.ftpEntry(e) +} + +// ftpAbort sends ABOR out of band and validates the reply against the codes MRI +// accepts (426/226/225), raising FTPProtoError otherwise (Net::FTP#abort). +func (vm *VM) ftpAbort(f *ftpObj) string { + f.writeLine(netftp.AborCommand) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + if !netftp.AbortAccepted(resp) { + raise("Net::FTPProtoError", "%s", resp) + } + return resp +} + +// --- get / put --------------------------------------------------------------- + +// ftpGetBinary implements getbinaryfile / the binary branch of get: it yields +// each block to a block, or (with no block) retrieves the file into localfile +// (defaulting to the remote basename). +func (vm *VM) ftpGetBinary(f *ftpObj, a []object.Value, blk *Proc) object.Value { + remote := strArg(a[0]) + bs := ftpBlocksizeArg(a, 2) + if blk != nil { + f.retrbinary(netftp.RetrCommand(remote), bs, func(chunk []byte) { + vm.callBlock(blk, []object.Value{object.NewStringBytesEnc(chunk, "ASCII-8BIT")}) + }) + return object.NilV + } + local := ftpOptString(a, 1, ftpBasename(remote)) + var buf bytes.Buffer + f.retrbinary(netftp.RetrCommand(remote), bs, func(chunk []byte) { buf.Write(chunk) }) + if err := os.WriteFile(local, buf.Bytes(), 0o644); err != nil { + raise("Errno::EACCES", "%s", err.Error()) + } + return object.NilV +} + +// ftpGetText implements gettextfile / the text branch of get: it yields each +// line to a block, or (with no block) retrieves the file into localfile, joining +// the received lines with newlines. +func (vm *VM) ftpGetText(f *ftpObj, a []object.Value, blk *Proc) object.Value { + remote := strArg(a[0]) + if blk != nil { + f.retrlines(netftp.RetrCommand(remote), func(line string) { + vm.callBlock(blk, []object.Value{object.NewString(line)}) + }) + return object.NilV + } + local := ftpOptString(a, 1, ftpBasename(remote)) + var b strings.Builder + f.retrlines(netftp.RetrCommand(remote), func(line string) { + b.WriteString(line) + b.WriteByte('\n') + }) + if err := os.WriteFile(local, []byte(b.String()), 0o644); err != nil { + raise("Errno::EACCES", "%s", err.Error()) + } + return object.NilV +} + +// ftpPutBinary implements putbinaryfile / the binary branch of put: it uploads +// localfile's bytes with STOR (remotefile defaults to the local basename). +func (vm *VM) ftpPutBinary(f *ftpObj, a []object.Value) object.Value { + local := strArg(a[0]) + data, err := os.ReadFile(local) + if err != nil { + raise("Errno::ENOENT", "%s", err.Error()) + } + remote := ftpOptString(a, 1, ftpBasename(local)) + f.storbinary(netftp.StorCommand(remote), data, ftpBlocksizeArg(a, 2)) + return object.NilV +} + +// ftpPutText implements puttextfile / the text branch of put: it uploads +// localfile as ASCII lines with STOR (remotefile defaults to the local +// basename). +func (vm *VM) ftpPutText(f *ftpObj, a []object.Value) object.Value { + local := strArg(a[0]) + data, err := os.ReadFile(local) + if err != nil { + raise("Errno::ENOENT", "%s", err.Error()) + } + remote := ftpOptString(a, 1, ftpBasename(local)) + f.storlines(netftp.StorCommand(remote), string(data)) + return object.NilV +} + +// --- MLSxEntry --------------------------------------------------------------- + +// ftpEntry wraps a parsed codec MLSxEntry in its Ruby class. +func (vm *VM) ftpEntry(e netftp.MLSxEntry) *ftpMLSxEntry { + return &ftpMLSxEntry{cls: vm.consts["Net::FTP::MLSxEntry"].(*RClass), e: e} +} + +// ftpEntrySelf recovers the wrapper from a receiver. +func ftpEntrySelf(v object.Value) *ftpMLSxEntry { return v.(*ftpMLSxEntry) } + +// registerFTPMLSxEntry installs Net::FTP::MLSxEntry: the pathname, the facts +// Hash (integer / time / string values), the type predicates, and the perm-bit +// predicates. +func (vm *VM) registerFTPMLSxEntry(cls *RClass) { + cls.define("pathname", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(ftpEntrySelf(v).e.Pathname) + }) + cls.define("facts", func(vm *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return vm.ftpFacts(ftpEntrySelf(v).e) + }) + cls.define("file?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(ftpEntrySelf(v).e.IsFile()) + }) + cls.define("directory?", func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(ftpEntrySelf(v).e.IsDirectory()) + }) + // The perm-bit predicates all delegate to the codec's MLSxEntry queries; one + // closure keyed by the predicate backs them all. + perms := map[string]func(netftp.MLSxEntry) bool{ + "appendable?": netftp.MLSxEntry.Appendable, + "creatable?": netftp.MLSxEntry.Creatable, + "deletable?": netftp.MLSxEntry.Deletable, + "enterable?": netftp.MLSxEntry.Enterable, + "renamable?": netftp.MLSxEntry.Renamable, + "listable?": netftp.MLSxEntry.Listable, + "directory_makable?": netftp.MLSxEntry.DirectoryMakable, + "purgeable?": netftp.MLSxEntry.Purgeable, + "readable?": netftp.MLSxEntry.Readable, + "writable?": netftp.MLSxEntry.Writable, + } + for name, fn := range perms { + pred := fn + cls.define(name, func(_ *VM, v object.Value, _ []object.Value, _ *Proc) object.Value { + return object.Bool(pred(ftpEntrySelf(v).e)) + }) + } +} + +// ftpFacts maps an entry's facts (in appearance order) to a Ruby Hash: integer +// facts become Integer, time facts a UTC Time, the rest String. +func (vm *VM) ftpFacts(e netftp.MLSxEntry) object.Value { + h := object.NewHash() + for _, name := range e.FactOrder { + fv := e.Facts[name] + var val object.Value + switch fv.Kind { + case netftp.FactInt: + val = object.IntValue(int64(fv.Int)) + case netftp.FactTime: + val = vm.ftpTimeVal(fv.Time) + default: + val = object.NewString(fv.Str) + } + h.Set(object.NewString(name), val) + } + return h +} + +// ftpTimeVal builds a UTC Ruby Time from an MLSx time-val's components. +func (vm *VM) ftpTimeVal(t netftp.MLSxTime) object.Value { + sec := stdtime.Date(t.Year, stdtime.Month(t.Month), t.Day, t.Hour, t.Min, t.Sec, 0, stdtime.UTC).Unix() + return &Time{t: gotime.FromUnix(sec).UTC()} +} diff --git a/internal/vm/net_ftp_bind.go b/internal/vm/net_ftp_bind.go new file mode 100644 index 00000000..999d542d --- /dev/null +++ b/internal/vm/net_ftp_bind.go @@ -0,0 +1,486 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "errors" + "net" + "strconv" + "strings" + + netftp "github.com/go-ruby-net-ftp/net-ftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the transport half of the Net::FTP binding: it drives real +// control and data sockets (Go net, exactly as socket.go / nethttp_bind.go do) +// on top of the interpreter-independent FTP codec +// github.com/go-ruby-net-ftp/net-ftp. The codec owns every observable byte — +// command lines, reply classification, PASV/EPSV host:port extraction, MLSx +// parsing; rbgo owns only the sockets. net_ftp.go builds the Ruby class surface +// and calls into the helpers here. +// +// The control connection is a single net.Conn wrapped in a bufio.Reader (so the +// codec's LineReader can pull whole reply lines). Each data transfer opens a +// second connection: in passive mode rbgo dials the host:port the codec +// extracts from the PASV/EPSV reply; in active mode rbgo listens on the control +// socket's local interface, sends PORT/EPRT, and accepts the server's callback. +// A transport read/write failure surfaces as Net::FTPConnectionError; a codec +// FTPError (reply/temp/perm/proto) re-raises as its matching Ruby class. + +// ftpDefaultBlocksize is Net::FTP::DEFAULT_BLOCKSIZE — the chunk size the binary +// transfer helpers read and write in. +const ftpDefaultBlocksize = 4096 + +// ftpObj is the Ruby wrapper around a live FTP control connection (Net::FTP). It +// owns the control socket and the transfer state (binary/passive/EPSV mode); the +// codec supplies the protocol, rbgo supplies the sockets. +type ftpObj struct { + cls *RClass + conn net.Conn + r *bufio.Reader + // binary is the current transfer type (true = TYPE I, false = TYPE A). It + // tracks the last TYPE the host actually sent so a transfer only re-sends TYPE + // when the mode changes, mirroring Net::FTP. + binary bool + // curType is the TYPE last negotiated with the server ("" before any transfer, + // "I" or "A" after), distinct from binary so the first transfer always sends + // TYPE. + curType string + // passive selects passive (PASV/EPSV) vs active (PORT/EPRT) data connections; + // Net::FTP defaults to passive. + passive bool + // epsv selects EPSV/EPRT over PASV/PORT. Net::FTP uses the extended commands + // when the control connection is IPv6; ftpIsIPv6 sets it at connect time. + epsv bool + // usePasvIP mirrors Net::FTP#use_pasv_ip: when true a PASV reply's encoded host + // is dialed, otherwise the control socket's address is used. + usePasvIP bool + // lastResponse is the most recent assembled reply (Net::FTP#last_response). + lastResponse string + // welcome is the server's connect greeting (Net::FTP#welcome). + welcome string + // closed reports whether the control connection has been closed. + closed bool +} + +func (f *ftpObj) ToS() string { return "#" } +func (f *ftpObj) Inspect() string { return "#" } +func (f *ftpObj) Truthy() bool { return true } + +// doClose closes the control connection once (Net::FTP#close); a second call is +// a no-op. +func (f *ftpObj) doClose() { + if f.closed || f.conn == nil { + return + } + f.conn.Close() + f.closed = true +} + +// ftpMLSxEntry is the Ruby wrapper around a parsed MLSD/MLST entry +// (Net::FTP::MLSxEntry): the codec's MLSxEntry plus the class it reports. +type ftpMLSxEntry struct { + cls *RClass + e netftp.MLSxEntry +} + +func (m *ftpMLSxEntry) ToS() string { return "#" } +func (m *ftpMLSxEntry) Inspect() string { return m.ToS() } +func (m *ftpMLSxEntry) Truthy() bool { return true } + +// --- codec error mapping ---------------------------------------------------- + +// ftpRaise re-raises a codec error as its Ruby class: an *netftp.FTPError uses +// the reply/temp/perm/proto/connection class name the codec assigns, and any +// other (transport) error becomes Net::FTPConnectionError. +func ftpRaise(err error) { + var fe *netftp.FTPError + if errors.As(err, &fe) { + raise(fe.ClassName(), "%s", fe.Message) + } + raise("Net::FTPConnectionError", "%s", err.Error()) +} + +// ftpConnErr raises Net::FTPConnectionError for a transport (dial/read/write) +// failure. +func ftpConnErr(err error) { + raise("Net::FTPConnectionError", "%s", err.Error()) +} + +// --- control-connection primitives ------------------------------------------ + +// reader returns the codec LineReader over the buffered control connection; it +// yields whole reply lines (terminator included — the codec strips it). +func (f *ftpObj) reader() netftp.LineReader { + return func() (string, error) { + line, err := f.r.ReadString('\n') + if err != nil && line == "" { + return "", err + } + return line, nil + } +} + +// writeLine builds the command bytes (PutLine appends CRLF and rejects an +// embedded CR/LF) and writes them to the control socket. +func (f *ftpObj) writeLine(line string) { + bytes, err := netftp.PutLine(line) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + if _, werr := f.conn.Write([]byte(bytes)); werr != nil { + ftpConnErr(werr) + } +} + +// getResp reads one assembled reply, records it as last_response, and classifies +// it: a 4yz/5yz/malformed reply re-raises as the matching Net::FTP error, a +// transport read failure as Net::FTPConnectionError. +func (f *ftpObj) getResp() string { + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + ftpConnErr(err) + } + f.lastResponse = resp + got, cerr := netftp.ClassifyReply(resp) + if cerr != nil { + ftpRaise(cerr) + } + return got +} + +// sendCmd writes a command and returns its classified reply (Net::FTP#sendcmd). +func (f *ftpObj) sendCmd(line string) string { + f.writeLine(line) + return f.getResp() +} + +// voidResp reads a reply and requires it to begin with '2' (Net::FTP#voidresp). +func (f *ftpObj) voidResp() { + resp := f.getResp() + if !strings.HasPrefix(resp, "2") { + raise("Net::FTPReplyError", "%s", resp) + } +} + +// voidCmd writes a command and requires a 2yz reply (Net::FTP#voidcmd). +func (f *ftpObj) voidCmd(line string) { + f.writeLine(line) + f.voidResp() +} + +// --- addressing helpers ----------------------------------------------------- + +// remoteHost is the control socket's remote address host — the host EPSV (which +// encodes none) and a use_pasv_ip=false PASV dial through. +func (f *ftpObj) remoteHost() string { + host, _, err := net.SplitHostPort(f.conn.RemoteAddr().String()) + if err != nil { + return f.conn.RemoteAddr().String() + } + return host +} + +// localHost is the control socket's local address host, the interface an +// active-mode data listener binds. +func (f *ftpObj) localHost() string { + host, _, err := net.SplitHostPort(f.conn.LocalAddr().String()) + if err != nil { + return f.conn.LocalAddr().String() + } + return host +} + +// ftpIsIPv6 reports whether a connection's remote address is an IPv6 TCP +// address, the condition under which Net::FTP uses EPSV/EPRT. +func ftpIsIPv6(c net.Conn) bool { + if a, ok := c.RemoteAddr().(*net.TCPAddr); ok { + return a.IP.To4() == nil + } + return false +} + +// --- data connections ------------------------------------------------------- + +// makepasv negotiates a passive data address, mirroring Net::FTP#makepasv: EPSV +// (parse229) for an IPv6 control connection, PASV (parse227) otherwise. +func (f *ftpObj) makepasv() (string, int) { + if f.epsv { + host, port, err := netftp.Parse229(f.sendCmd(netftp.EpsvCommand), f.remoteHost()) + if err != nil { + ftpRaise(err) + } + return host, port + } + host, port, err := netftp.Parse227(f.sendCmd(netftp.PasvCommand), f.usePasvIP, f.remoteHost()) + if err != nil { + ftpRaise(err) + } + return host, port +} + +// makeport opens an active-mode data listener on the control socket's local +// interface and announces it with PORT (SendPort) or EPRT (SendEPort). +func (f *ftpObj) makeport() net.Listener { + host := f.localHost() + ln, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + ftpConnErr(err) + } + _, portStr, _ := net.SplitHostPort(ln.Addr().String()) + port, _ := strconv.Atoi(portStr) + if f.epsv { + f.voidCmd(netftp.SendEPort(host, port)) + } else { + f.voidCmd(netftp.SendPort(host, port)) + } + return ln +} + +// ftpMarkOK reports whether a transfer command's reply is a preliminary mark +// (1yz) or an immediate completion (2yz) — the replies Net::FTP#transfercmd +// accepts before reading the data connection. +func ftpMarkOK(resp string) bool { + return strings.HasPrefix(resp, "1") || strings.HasPrefix(resp, "2") +} + +// transfercmd opens a data connection and issues the transfer command over the +// control connection, returning the ready data socket (Net::FTP#transfercmd). +// The caller reads or writes the data, closes it, and then calls voidResp for +// the completion reply. A command reply that is an error (or not a mark) closes +// the just-opened data socket/listener before raising, so no fd or goroutine +// leaks on the failure path. +func (f *ftpObj) transfercmd(cmd string) net.Conn { + if f.passive { + host, port := f.makepasv() + conn := f.dialData(host, port) + f.sendTransferCmd(cmd, func() { conn.Close() }) + return conn + } + ln := f.makeport() + f.sendTransferCmd(cmd, func() { ln.Close() }) + conn := f.acceptData(ln) + ln.Close() + return conn +} + +// dialData dials a passive data address, raising Net::FTPConnectionError when the +// connection cannot be made. +func (f *ftpObj) dialData(host string, port int) net.Conn { + conn, err := net.Dial("tcp", net.JoinHostPort(host, strconv.Itoa(port))) + if err != nil { + ftpConnErr(err) + } + return conn +} + +// acceptData accepts the server's active-mode data callback, raising +// Net::FTPConnectionError when the accept fails. +func (f *ftpObj) acceptData(ln net.Listener) net.Conn { + conn, err := ln.Accept() + if err != nil { + ftpConnErr(err) + } + return conn +} + +// sendData writes data to the data connection in blocksize chunks, closing it +// and raising Net::FTPConnectionError on a write failure. +func (f *ftpObj) sendData(conn net.Conn, data []byte, blocksize int) { + for off := 0; off < len(data); off += blocksize { + end := min(off+blocksize, len(data)) + if _, err := conn.Write(data[off:end]); err != nil { + conn.Close() + ftpConnErr(err) + } + } +} + +// sendTransferCmd writes the transfer command and reads its reply raw (bypassing +// getResp's raise) so cleanup — closing the already-opened data socket or +// listener — runs before any error is raised: a read failure or 4yz/5yz reply +// re-raises the codec error, and a non-mark reply (a stray 3yz) raises +// FTPReplyError. +func (f *ftpObj) sendTransferCmd(cmd string, cleanup func()) { + f.writeLine(cmd) + resp, err := netftp.GetMultiline(f.reader()) + if err != nil { + cleanup() + ftpConnErr(err) + } + f.lastResponse = resp + if _, cerr := netftp.ClassifyReply(resp); cerr != nil { + cleanup() + ftpRaise(cerr) + } + if !ftpMarkOK(resp) { + cleanup() + raise("Net::FTPReplyError", "%s", resp) + } +} + +// setType negotiates the transfer TYPE (I/A) when it differs from the last one +// sent, mirroring Net::FTP's lazy send_type_command. +func (f *ftpObj) setType(binary bool) { + want := "A" + if binary { + want = "I" + } + if f.curType == want { + return + } + f.voidCmd(netftp.TypeCommand(binary)) + f.curType = want +} + +// --- transfer engines ------------------------------------------------------- + +// retrlines runs an ASCII retrieval, yielding each received line (its trailing +// CRLF/LF removed) to fn, then reads the completion reply +// (Net::FTP#retrlines). +func (f *ftpObj) retrlines(cmd string, fn func(string)) { + f.setType(false) + conn := f.transfercmd(cmd) + br := bufio.NewReader(conn) + for { + line, err := br.ReadString('\n') + if line != "" { + fn(ftpChomp(line)) + } + if err != nil { + break + } + } + conn.Close() + f.voidResp() +} + +// retrbinary runs a binary retrieval, yielding each block of at most blocksize +// bytes to fn, then reads the completion reply (Net::FTP#retrbinary). +func (f *ftpObj) retrbinary(cmd string, blocksize int, fn func([]byte)) { + f.setType(true) + conn := f.transfercmd(cmd) + buf := make([]byte, blocksize) + for { + n, err := conn.Read(buf) + if n > 0 { + chunk := make([]byte, n) + copy(chunk, buf[:n]) + fn(chunk) + } + if err != nil { + break + } + } + conn.Close() + f.voidResp() +} + +// storbinary runs a binary store, writing data in blocksize chunks over the data +// connection, then reads the completion reply (Net::FTP#storbinary). +func (f *ftpObj) storbinary(cmd string, data []byte, blocksize int) { + f.setType(true) + conn := f.transfercmd(cmd) + f.sendData(conn, data, blocksize) + conn.Close() + f.voidResp() +} + +// storlines runs an ASCII store, writing data (each bare LF promoted to CRLF) +// over the data connection, then reads the completion reply +// (Net::FTP#storlines). +func (f *ftpObj) storlines(cmd string, data string) { + f.setType(false) + conn := f.transfercmd(cmd) + f.sendData(conn, []byte(ftpToCRLF(data)), ftpDefaultBlocksize) + conn.Close() + f.voidResp() +} + +// --- small text helpers ----------------------------------------------------- + +// ftpChomp removes a single trailing CRLF / LF / CR record separator, matching +// the line boundaries Net::FTP#retrlines yields. +func ftpChomp(s string) string { + if strings.HasSuffix(s, "\r\n") { + return s[:len(s)-2] + } + if n := len(s); n > 0 { + if c := s[n-1]; c == '\n' || c == '\r' { + return s[:n-1] + } + } + return s +} + +// ftpToCRLF promotes each bare LF (one not already preceded by CR) to CRLF, the +// line-ending normalisation Net::FTP#storlines applies to ASCII uploads. +func ftpToCRLF(s string) string { + var b strings.Builder + for i := 0; i < len(s); i++ { + c := s[i] + if c == '\n' && (i == 0 || s[i-1] != '\r') { + b.WriteByte('\r') + } + b.WriteByte(c) + } + return b.String() +} + +// ftpBasename returns the final path element of p (File.basename), the default +// remote/local name the get/put helpers derive from their counterpart. +func ftpBasename(p string) string { + if i := strings.LastIndexByte(p, '/'); i >= 0 { + return p[i+1:] + } + return p +} + +// --- argument helpers ------------------------------------------------------- + +// ftpArity raises an ArgumentError when args has fewer than min or more than max +// elements. +func ftpArity(args []object.Value, min, max int, name string) { + if len(args) < min || len(args) > max { + raise("ArgumentError", "wrong number of arguments for '%s' (given %d)", name, len(args)) + } +} + +// ftpOptString returns the i-th argument as a String, or def when it is absent +// or nil (the optional pathname arguments Net::FTP commands accept). +func ftpOptString(args []object.Value, i int, def string) string { + if i >= len(args) { + return def + } + if _, ok := args[i].(object.Nil); ok { + return def + } + return strArg(args[i]) +} + +// ftpBytes returns the raw bytes of a String argument (for a binary upload +// body). +func ftpBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + raise("TypeError", "no implicit conversion of %s into String", v.Inspect()) + return nil +} + +// ftpBlocksizeArg reads an optional trailing blocksize argument, defaulting to +// ftpDefaultBlocksize. +func ftpBlocksizeArg(args []object.Value, i int) int { + if i >= len(args) { + return ftpDefaultBlocksize + } + if n, ok := args[i].(object.Integer); ok && int(n) > 0 { + return int(n) + } + return ftpDefaultBlocksize +} diff --git a/internal/vm/net_ftp_internal_test.go b/internal/vm/net_ftp_internal_test.go new file mode 100644 index 00000000..93b355f7 --- /dev/null +++ b/internal/vm/net_ftp_internal_test.go @@ -0,0 +1,1531 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "bufio" + "fmt" + "net" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + stdtime "time" + + netftp "github.com/go-ruby-net-ftp/net-ftp" +) + +// The Net::FTP binding is exercised against an in-process FTP server (below) so +// every path — control replies, PASV/EPSV/PORT/EPRT data connections, the +// LIST/NLST/MLSD/MLST listings, retr/stor transfers, and every error branch — is +// deterministic, uses only the loopback interface, and leaks no goroutine (the +// server's connections are force-closed and joined on cleanup). + +// --- in-process FTP test server --------------------------------------------- + +// ftpServer is a scriptable loopback FTP server. Default handlers implement a +// tiny virtual filesystem; overrides force a specific reply (or connection +// close) for a verb, and dataOverride replaces a listing's data payload, so the +// tests can drive both the happy paths and every error branch. +type ftpServer struct { + ln net.Listener + wg sync.WaitGroup + mu sync.Mutex + conns []net.Conn + datals []net.Listener + overrides map[string]string // VERB -> forced reply line ("@close" closes) + dataOverride map[string]string // VERB -> data-channel payload + files map[string]string // RETR content by path + stored map[string]string // STOR content captured by path +} + +// connState is one control connection's transfer state. +type connState struct { + cwd string + dataLn net.Listener // passive: listener awaiting the client's data dial + dataAddr string // active: the client's PORT/EPRT address to dial back + renameFr string +} + +func newFTPServer(t *testing.T) *ftpServer { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatalf("listen: %v", err) + } + s := &ftpServer{ + ln: ln, + overrides: map[string]string{}, + dataOverride: map[string]string{}, + files: map[string]string{}, + stored: map[string]string{}, + } + s.wg.Add(1) + go s.acceptLoop() + t.Cleanup(func() { + ln.Close() + s.mu.Lock() + for _, c := range s.conns { + c.Close() + } + for _, d := range s.datals { + d.Close() + } + s.mu.Unlock() + s.wg.Wait() + }) + return s +} + +func (s *ftpServer) port() int { return s.ln.Addr().(*net.TCPAddr).Port } + +// The override / dataOverride maps are read by connection goroutines and mutated +// by the tests between runs, so all access goes through these mutex-guarded +// accessors (the -race detector holds the gate). +func (s *ftpServer) setOverride(verb, reply string) { + s.mu.Lock() + s.overrides[verb] = reply + s.mu.Unlock() +} +func (s *ftpServer) clearOverride(verb string) { + s.mu.Lock() + delete(s.overrides, verb) + s.mu.Unlock() +} +func (s *ftpServer) getOverride(verb string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.overrides[verb] + return v, ok +} +func (s *ftpServer) setDataOverride(verb, payload string) { + s.mu.Lock() + s.dataOverride[verb] = payload + s.mu.Unlock() +} +func (s *ftpServer) clearDataOverride(verb string) { + s.mu.Lock() + delete(s.dataOverride, verb) + s.mu.Unlock() +} +func (s *ftpServer) getDataOverride(verb string) (string, bool) { + s.mu.Lock() + defer s.mu.Unlock() + v, ok := s.dataOverride[verb] + return v, ok +} + +func (s *ftpServer) acceptLoop() { + defer s.wg.Done() + for { + c, err := s.ln.Accept() + if err != nil { + return + } + s.mu.Lock() + s.conns = append(s.conns, c) + s.mu.Unlock() + s.wg.Add(1) + go s.handleConn(c) + } +} + +// run compiles and runs a Ruby program with `require "net/ftp"` and a PORT +// constant prepended, returning its trimmed stdout. +func (s *ftpServer) run(t *testing.T, body string) string { + t.Helper() + return runSrc(t, "require \"net/ftp\"\nPORT = "+strconv.Itoa(s.port())+"\n"+body) +} + +func (s *ftpServer) handleConn(c net.Conn) { + defer s.wg.Done() + defer c.Close() + br := bufio.NewReader(c) + w := func(str string) { c.Write([]byte(str)) } + w("220 rbgo test FTP ready\r\n") + st := &connState{cwd: "/"} + for { + raw, err := br.ReadString('\n') + if err != nil { + return + } + line := strings.TrimRight(raw, "\r\n") + verb, arg := line, "" + if i := strings.IndexByte(line, ' '); i >= 0 { + verb, arg = line[:i], line[i+1:] + } + VERB := strings.ToUpper(verb) + if ov, ok := s.getOverride(VERB); ok { + if st.dataLn != nil { // a forced error on a transfer verb: release the data listener + st.dataLn.Close() + st.dataLn = nil + } + if ov == "@close" { + return + } + w(ov) + if VERB == "QUIT" { + return + } + continue + } + if s.dispatch(w, st, VERB, arg) { + return + } + } +} + +// dispatch handles one command; it returns true when the connection should close. +func (s *ftpServer) dispatch(w func(string), st *connState, verb, arg string) bool { + switch verb { + case "USER": + w("331 need password\r\n") + case "PASS": + w("230 logged in\r\n") + case "ACCT": + w("230 account ok\r\n") + case "TYPE": + w("200 type set\r\n") + case "PWD", "XPWD": + w("257 \"" + st.cwd + "\" is current directory\r\n") + case "CWD": + st.cwd = arg + w("250 directory changed\r\n") + case "CDUP": + w("200 cdup ok\r\n") + case "MKD": + w("257 \"" + arg + "\" created\r\n") + case "RMD": + w("250 removed\r\n") + case "DELE": + w("250 deleted\r\n") + case "RNFR": + st.renameFr = arg + w("350 ready for RNTO\r\n") + case "RNTO": + w("250 renamed\r\n") + case "SIZE": + w("213 " + strconv.Itoa(len(s.content(arg))) + "\r\n") + case "MDTM": + w("213 20200102030405\r\n") + case "SYST": + w("215 UNIX Type: L8\r\n") + case "STAT": + w("211 status ok\r\n") + case "NOOP": + w("200 noop\r\n") + case "HELP": + w("214 help text\r\n") + case "SITE": + w("200 site ok\r\n") + case "PASV": + s.openPassive(w, st, false) + case "EPSV": + s.openPassive(w, st, true) + case "PORT", "EPRT": + st.dataAddr = s.parseActive(verb, arg) + w("200 port ok\r\n") + case "MLST": + w("250-Listing " + arg + "\r\n type=file;size=12;modify=20200102030405; " + s.pathOf(arg) + "\r\n250 End\r\n") + case "LIST", "NLST", "MLSD": + s.doSend(w, st, verb, arg) + case "RETR": + s.doRetr(w, st, arg) + case "STOR": + s.doStor(w, st, arg) + case "QUIT": + w("221 bye\r\n") + return true + case "ABOR": + w("226 abort ok\r\n") + default: + w("500 unknown command\r\n") + } + return false +} + +func (s *ftpServer) pathOf(arg string) string { + if arg == "" { + return "/file.txt" + } + return arg +} + +func (s *ftpServer) content(arg string) string { + if c, ok := s.files[arg]; ok { + return c + } + return "line one\nline two\n" +} + +// openPassive opens a data listener and replies with the PASV (227) or EPSV +// (229) address of the loopback data port. +func (s *ftpServer) openPassive(w func(string), st *connState, epsv bool) { + dl, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + w("425 cannot open data connection\r\n") + return + } + s.mu.Lock() + s.datals = append(s.datals, dl) + s.mu.Unlock() + st.dataLn = dl + port := dl.Addr().(*net.TCPAddr).Port + if epsv { + w(fmt.Sprintf("229 Entering Extended Passive Mode (|||%d|)\r\n", port)) + return + } + w(fmt.Sprintf("227 Entering Passive Mode (127,0,0,1,%d,%d)\r\n", port/256, port%256)) +} + +// parseActive parses a PORT (h1,h2,h3,h4,p1,p2) or EPRT (|proto|host|port|) +// argument into the dial-back address. +func (s *ftpServer) parseActive(verb, arg string) string { + if verb == "EPRT" { + parts := strings.Split(arg, "|") + return net.JoinHostPort(parts[2], parts[3]) + } + f := strings.Split(arg, ",") + host := strings.Join(f[:4], ".") + hi, _ := strconv.Atoi(f[4]) + lo, _ := strconv.Atoi(f[5]) + return net.JoinHostPort(host, strconv.Itoa(hi*256+lo)) +} + +// openData accepts (passive) or dials (active) the data connection. +func (s *ftpServer) openData(st *connState) net.Conn { + if st.dataLn != nil { + conn, err := st.dataLn.Accept() + st.dataLn.Close() + st.dataLn = nil + if err != nil { + return nil + } + return conn + } + conn, err := net.Dial("tcp", st.dataAddr) + if err != nil { + return nil + } + return conn +} + +func (s *ftpServer) doSend(w func(string), st *connState, verb, arg string) { + payload, ok := s.getDataOverride(verb) + if !ok { + switch verb { + case "LIST": + payload = "drwxr-xr-x 2 u g 4096 Jan 01 00:00 dir1\r\n-rw-r--r-- 1 u g 12 Jan 01 00:00 file.txt\r\n" + case "NLST": + payload = "dir1\r\nfile.txt\r\n" + default: // MLSD + payload = "type=dir;perm=el;unix.mode=0755; dir1\r\n" + + "type=file;size=12;perm=rw;modify=20200102030405;unix.owner=1000; file.txt\r\n" + } + } + w("150 opening data connection\r\n") + conn := s.openData(st) + if conn != nil { + conn.Write([]byte(payload)) + conn.Close() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) doRetr(w func(string), st *connState, arg string) { + w("150 opening data connection\r\n") + conn := s.openData(st) + if conn != nil { + conn.Write([]byte(s.content(arg))) + conn.Close() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) doStor(w func(string), st *connState, arg string) { + w("150 ready to receive\r\n") + conn := s.openData(st) + if conn != nil { + var b strings.Builder + buf := make([]byte, 512) + for { + n, err := conn.Read(buf) + if n > 0 { + b.Write(buf[:n]) + } + if err != nil { + break + } + } + conn.Close() + s.mu.Lock() + s.stored[arg] = b.String() + s.mu.Unlock() + } + w("226 transfer complete\r\n") +} + +func (s *ftpServer) storedContent(path string) string { + s.mu.Lock() + defer s.mu.Unlock() + return s.stored[path] +} + +// --- fakeConn / fakeAddr for the addressing-helper unit tests --------------- + +type fakeAddr string + +func (a fakeAddr) Network() string { return "fake" } +func (a fakeAddr) String() string { return string(a) } + +type fakeConn struct { + remote net.Addr + local net.Addr +} + +func (c *fakeConn) Read([]byte) (int, error) { return 0, nil } +func (c *fakeConn) Write(p []byte) (int, error) { return len(p), nil } +func (c *fakeConn) Close() error { return nil } +func (c *fakeConn) LocalAddr() net.Addr { return c.local } +func (c *fakeConn) RemoteAddr() net.Addr { return c.remote } +func (c *fakeConn) SetDeadline(stdtime.Time) error { return nil } +func (c *fakeConn) SetReadDeadline(stdtime.Time) error { return nil } +func (c *fakeConn) SetWriteDeadline(stdtime.Time) error { return nil } + +// dialRaw dials the server and returns a bare ftpObj (greeting consumed), for +// the direct-helper tests that need to flip unexported state (epsv / passive). +func dialRaw(t *testing.T, s *ftpServer) *ftpObj { + t.Helper() + conn, err := net.Dial("tcp", s.ln.Addr().String()) + if err != nil { + t.Fatalf("dial: %v", err) + } + f := &ftpObj{conn: conn, r: bufio.NewReader(conn), binary: true, passive: true} + if _, err := f.r.ReadString('\n'); err != nil { // greeting + t.Fatalf("greeting: %v", err) + } + return f +} + +// --- tests: happy-path Ruby surface ----------------------------------------- + +func TestFTPRequireAndClassTree(t *testing.T) { + got := runSrc(t, ` +p require "net/ftp" +p require "net/ftp" +p Net::FTP.is_a?(Class) +p Net::FTP::MLSxEntry.is_a?(Class) +p Net::FTPError < StandardError +p Net::FTPReplyError < Net::FTPError +p Net::FTPTempError < Net::FTPError +p Net::FTPPermError < Net::FTPError +p Net::FTPProtoError < Net::FTPError +p Net::FTPConnectionError < Net::FTPError +`) + want := "true\nfalse\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue\ntrue" + if got != want { + t.Fatalf("require/class tree got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPConnectLoginBasics(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT) +puts ftp.welcome +ftp.login +puts ftp.system +puts ftp.pwd +puts ftp.getdir +puts ftp.last_response_code +puts ftp.lastresp +puts ftp.closed? +ftp.quit +ftp.close +puts ftp.closed? +`) + want := "220 rbgo test FTP ready\nUNIX Type: L8\n/\n/\n257\n257\nfalse\ntrue" + if got != want { + t.Fatalf("got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPLoginVariants(t *testing.T) { + s := newFTPServer(t) + // Anonymous default password (USER anonymous -> 331 -> PASS anonymous@). + if got := s.run(t, `Net::FTP.new("127.0.0.1", port: PORT).login; puts "ok"`); got != "ok" { + t.Fatalf("anonymous: %q", got) + } + // new via options username/password. + if got := s.run(t, `ftp = Net::FTP.new("127.0.0.1", port: PORT, username: "bob", password: "sekret"); puts ftp.welcome[0,3]`); got != "220" { + t.Fatalf("opts login: %q", got) + } +} + +func TestFTPLoginAccountLadder(t *testing.T) { + s := newFTPServer(t) + s.setOverride("PASS", "332 need account\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT) +ftp.login("bob", "pw", "acct123") +puts "ok" +`) + if got != "ok" { + t.Fatalf("account ladder: %q", got) + } + // Missing account when the server demands one -> FTPReplyError. + got = s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("bob", "pw") +rescue Net::FTPReplyError => e + puts "reply-error" +end +`) + if got != "reply-error" { + t.Fatalf("missing acct: %q", got) + } +} + +func TestFTPLoginMissingPassword(t *testing.T) { + s := newFTPServer(t) + // USER -> 331 (needs pass) but no password supplied for a non-anonymous user. + got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("bob") +rescue Net::FTPReplyError + puts "need-pass" +end +`) + if got != "need-pass" { + t.Fatalf("missing pass: %q", got) + } +} + +func TestFTPDirectoryCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.chdir("sub") +ftp.chdir("..") +puts ftp.mkdir("newdir") +ftp.rmdir("newdir") +ftp.delete("x.txt") +ftp.rename("a", "b") +ftp.noop +ftp.site("chmod 755 x") +puts ftp.status +puts ftp.help +`) + want := "newdir\n211 status ok\n214 help text" + if got != want { + t.Fatalf("dir commands got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPChdirCdupFallback(t *testing.T) { + s := newFTPServer(t) + // CDUP not understood (500) -> chdir("..") falls back to CWD "..". + s.setOverride("CDUP", "500 not understood\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.chdir("..") +puts "fell-back" +`) + if got != "fell-back" { + t.Fatalf("cdup fallback: %q", got) + } + // CDUP hard error (550) -> raises (not a 500, so no fallback). + s.setOverride("CDUP", "550 denied\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPPermError + puts "perm" +end +`) + if got != "perm" { + t.Fatalf("cdup perm: %q", got) + } + // CDUP proto error (non-standard code) -> FTPProtoError via ClassifyReply. + s.setOverride("CDUP", "600 weird\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("cdup proto: %q", got) + } + // CDUP replies a stray 3yz (passes ClassifyReply, not 2yz, not 500) -> FTPReplyError. + s.setOverride("CDUP", "350 hmm\r\n") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.chdir("..") +rescue Net::FTPReplyError + puts "reply" +end +`) + if got != "reply" { + t.Fatalf("cdup reply: %q", got) + } +} + +func TestFTPMetadataCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.size("file.txt") +puts ftp.mdtm("file.txt") +puts ftp.mtime("file.txt").strftime("%Y-%m-%d %H:%M:%S") +`) + want := strconv.Itoa(len("line one\nline two\n")) + "\n20200102030405\n2020-01-02 03:04:05" + if got != want { + t.Fatalf("metadata got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPListings(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +p ftp.nlst +p ftp.list("/pub") +collected = [] +ftp.list { |l| collected << l } +p collected.size +`) + want := `["dir1", "file.txt"]` + "\n" + + `["drwxr-xr-x 2 u g 4096 Jan 01 00:00 dir1", "-rw-r--r-- 1 u g 12 Jan 01 00:00 file.txt"]` + "\n2" + if got != want { + t.Fatalf("listings got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlsd(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +entries = ftp.mlsd +d, f = entries +puts d.pathname +puts d.directory? +puts d.file? +puts d.listable? +puts f.pathname +puts f.file? +puts f.readable? +puts f.writable? +puts f.deletable? +puts f.facts["size"] +puts f.facts["modify"].strftime("%Y%m%d%H%M%S") +puts d.facts["unix.mode"] +puts f.facts["unix.owner"] +`) + want := "dir1\ntrue\nfalse\ntrue\nfile.txt\ntrue\ntrue\ntrue\nfalse\n12\n20200102030405\n493\n1000" + if got != want { + t.Fatalf("mlsd got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlsdBlockAndPermPredicates(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +names = [] +ftp.mlsd("/pub") { |e| names << e.pathname } +p names +e = ftp.mlsd.first +puts [e.appendable?, e.creatable?, e.enterable?, e.renamable?, e.directory_makable?, e.purgeable?].inspect +`) + want := `["dir1", "file.txt"]` + "\n[false, false, true, false, false, false]" + if got != want { + t.Fatalf("mlsd block/perm got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPMlst(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +e = ftp.mlst("file.txt") +puts e.pathname +puts e.file? +puts e.facts["size"] +`) + if got != "file.txt\ntrue\n12" { + t.Fatalf("mlst got:\n%q", got) + } +} + +func TestFTPTransferEngines(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +lines = [] +ftp.retrlines("RETR file.txt") { |l| lines << l } +p lines +chunks = "".b +ftp.retrbinary("RETR file.txt", 4) { |c| chunks << c } +p chunks +ftp.storlines("STOR up.txt", "a\nb\n") +ftp.storbinary("STOR bin.dat", "xyz".b, 2) +puts "done" +`) + want := `["line one", "line two"]` + "\n" + `"line one\nline two\n"` + "\ndone" + if got != want { + t.Fatalf("transfer engines got:\n%q\nwant:\n%q", got, want) + } + if c := s.storedContent("up.txt"); c != "a\r\nb\r\n" { + t.Fatalf("storlines captured %q", c) + } + if c := s.storedContent("bin.dat"); c != "xyz" { + t.Fatalf("storbinary captured %q", c) + } +} + +func TestFTPGetPutFiles(t *testing.T) { + s := newFTPServer(t) + dir := t.TempDir() + binLocal := filepath.Join(dir, "got.bin") + txtLocal := filepath.Join(dir, "got.txt") + upLocal := filepath.Join(dir, "up.txt") + if err := os.WriteFile(upLocal, []byte("upload me\n"), 0o644); err != nil { + t.Fatal(err) + } + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.getbinaryfile("file.txt", %q) +ftp.binary = false +ftp.gettextfile("file.txt", %q) +ftp.putbinaryfile(%q, "remote.bin") +ftp.binary = true +ftp.putbinaryfile(%q) +# block forms +bin = "".b +ftp.getbinaryfile("file.txt") { |c| bin << c } +p bin.bytesize +txt = [] +ftp.gettextfile("file.txt") { |l| txt << l } +p txt +puts ftp.binary +`, binLocal, txtLocal, upLocal, upLocal)) + if !strings.Contains(got, "18\n") { + t.Fatalf("get/put block got:\n%q", got) + } + if b, _ := os.ReadFile(binLocal); string(b) != "line one\nline two\n" { + t.Fatalf("getbinaryfile wrote %q", b) + } + if b, _ := os.ReadFile(txtLocal); string(b) != "line one\nline two\n" { + t.Fatalf("gettextfile wrote %q", b) + } + if c := s.storedContent("remote.bin"); c != "upload me\n" { + t.Fatalf("putbinaryfile captured %q", c) + } + if c := s.storedContent("up.txt"); c != "upload me\n" { // default remote basename + t.Fatalf("putbinaryfile default name captured %q", c) + } +} + +func TestFTPGetPutDispatchAndText(t *testing.T) { + s := newFTPServer(t) + dir := t.TempDir() + local := filepath.Join(dir, "d.txt") + local2 := filepath.Join(dir, "d2.txt") + up := filepath.Join(dir, "u.txt") + if err := os.WriteFile(up, []byte("hello\nworld\n"), 0o644); err != nil { + t.Fatal(err) + } + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.get("file.txt", %q) # binary dispatch (default binary) +ftp.puttextfile(%q, "textup.txt") +ftp.put(%q, "binput.txt") # binary dispatch (put) +ftp.binary = false +ftp.get("file.txt", %q) # text dispatch (get) +ftp.put(%q, "put.txt") # text dispatch (put) +puts "ok" +`, local, up, up, local2, up)) + if got != "ok" { + t.Fatalf("dispatch: %q", got) + } + if c := s.storedContent("binput.txt"); c != "hello\nworld\n" { + t.Fatalf("put binary captured %q", c) + } + if c := s.storedContent("textup.txt"); c != "hello\r\nworld\r\n" { + t.Fatalf("puttextfile captured %q", c) + } + if c := s.storedContent("put.txt"); c != "hello\r\nworld\r\n" { + t.Fatalf("put text captured %q", c) + } +} + +func TestFTPActiveMode(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +ftp.passive = false +puts ftp.passive +p ftp.nlst +ftp.storlines("STOR a.txt", "x\n") +puts "active-done" +`) + if !strings.Contains(got, "active-done") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("active mode got:\n%q", got) + } + if s.storedContent("a.txt") != "x\r\n" { + t.Fatalf("active stor captured %q", s.storedContent("a.txt")) + } +} + +func TestFTPUsePasvIP(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT, use_pasv_ip: true); ftp.login +puts ftp.use_pasv_ip +p ftp.nlst +`) + if !strings.Contains(got, "true") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("use_pasv_ip got:\n%q", got) + } +} + +func TestFTPEPSVAndEPRT(t *testing.T) { + s := newFTPServer(t) + // EPSV (passive + epsv): drive the helper directly with epsv flipped on. + f := dialRaw(t, s) + f.epsv = true + var lines []string + f.retrlines(netftp.NlstCommand(""), func(l string) { lines = append(lines, l) }) + f.doClose() + if strings.Join(lines, ",") != "dir1,file.txt" { + t.Fatalf("epsv nlst: %v", lines) + } + // EPRT (active + epsv). + f2 := dialRaw(t, s) + f2.epsv = true + f2.passive = false + var lines2 []string + f2.retrlines(netftp.NlstCommand(""), func(l string) { lines2 = append(lines2, l) }) + f2.doClose() + if strings.Join(lines2, ",") != "dir1,file.txt" { + t.Fatalf("eprt nlst: %v", lines2) + } +} + +func TestFTPOpenBlock(t *testing.T) { + s := newFTPServer(t) + // open with a block yields the client and closes it afterwards. + got := s.run(t, ` +res = Net::FTP.open("127.0.0.1", port: PORT) do |ftp| + ftp.login + ftp.pwd +end +puts res +`) + if got != "/" { + t.Fatalf("open block: %q", got) + } + // open without a block returns the client. + got = s.run(t, ` +ftp = Net::FTP.open("127.0.0.1", port: PORT) +ftp.login +puts ftp.pwd +ftp.close +`) + if got != "/" { + t.Fatalf("open no-block: %q", got) + } + // open's block close runs even when the block raises. + got = s.run(t, ` +begin + Net::FTP.open("127.0.0.1", port: PORT) { |ftp| ftp.login; raise "boom" } +rescue => e + puts e.message +end +`) + if got != "boom" { + t.Fatalf("open block raise: %q", got) + } +} + +func TestFTPNoHostConstructor(t *testing.T) { + s := newFTPServer(t) + // new with no host / nil host does not connect. + got := s.run(t, ` +ftp = Net::FTP.new +puts ftp.closed? +ftp2 = Net::FTP.new(nil) +puts ftp2.passive +ftp.connect("127.0.0.1", PORT) +ftp.login +puts ftp.pwd +`) + if got != "true\ntrue\n/" { + t.Fatalf("no-host constructor: %q", got) + } +} + +func TestFTPGenericCommands(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.sendcmd("NOOP")[0,3] +ftp.voidcmd("NOOP") +puts ftp.last_response[0,3] +puts ftp.abort[0,3] +`) + if got != "200\n200\n226" { + t.Fatalf("generic commands: %q", got) + } +} + +// --- tests: error branches -------------------------------------------------- + +func TestFTPErrorMapping(t *testing.T) { + s := newFTPServer(t) + cases := []struct{ reply, rescue, tag string }{ + {"421 service not available\r\n", "Net::FTPTempError", "temp"}, + {"550 permission denied\r\n", "Net::FTPPermError", "perm"}, + {"600 nonstandard\r\n", "Net::FTPProtoError", "proto"}, + } + for _, c := range cases { + s.setOverride("NOOP", c.reply) + got := s.run(t, fmt.Sprintf(` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.noop +rescue %s + puts %q +end +`, c.rescue, c.tag)) + if got != c.tag { + t.Fatalf("%s: got %q", c.tag, got) + } + } + s.clearOverride("NOOP") +} + +func TestFTPConnectionErrors(t *testing.T) { + // Dial to a closed port -> Net::FTPConnectionError. + closed := freePort(t) + got := runSrc(t, fmt.Sprintf(`require "net/ftp" +begin + Net::FTP.new("127.0.0.1", port: %d) +rescue Net::FTPConnectionError + puts "conn-err" +end`, closed)) + if got != "conn-err" { + t.Fatalf("dial error: %q", got) + } + // Server closes the control connection before replying -> read failure. + s := newFTPServer(t) + s.setOverride("NOOP", "@close") + got = s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.noop +rescue Net::FTPConnectionError + puts "read-err" +end`) + if got != "read-err" { + t.Fatalf("read error: %q", got) + } +} + +func TestFTPCommandErrorBranches(t *testing.T) { + s := newFTPServer(t) + // Each of these replies passes getResp (1/2/3yz) but fails the command's own + // classifier, exercising the ftpRaise error branch in that method. + type tc struct { + verb, reply, body, rescue, tag string + } + cases := []tc{ + {"DELE", "200 ok\r\n", `ftp.delete("x")`, "Net::FTPReplyError", "dele"}, + {"RNFR", "200 ok\r\n", `ftp.rename("a", "b")`, "Net::FTPReplyError", "rnfr"}, + {"SIZE", "200 nope\r\n", `ftp.size("x")`, "Net::FTPReplyError", "size"}, + {"SYST", "200 nope\r\n", `ftp.system`, "Net::FTPReplyError", "syst"}, + {"MDTM", "200 nope\r\n", `ftp.mdtm("x")`, "Net::FTPReplyError", "mdtm"}, + {"MDTM", "200 nope\r\n", `ftp.mtime("x")`, "Net::FTPReplyError", "mtime"}, + {"MKD", "250 no-quotes here\r\n", `ftp.mkdir("d")`, "Net::FTPReplyError", "mkd"}, + {"PWD", "250 no-quotes here\r\n", `ftp.pwd`, "Net::FTPReplyError", "pwd"}, + } + for _, c := range cases { + s.setOverride(c.verb, c.reply) + got := s.run(t, fmt.Sprintf(` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + %s +rescue %s + puts %q +end +`, c.body, c.rescue, c.tag)) + if got != c.tag { + t.Fatalf("%s (verb %s): got %q", c.tag, c.verb, got) + } + s.clearOverride(c.verb) + } +} + +func TestFTPPwdParse257Empty(t *testing.T) { + s := newFTPServer(t) + // 257 without a quoted string decodes to "" (Parse257 no-match branch). + s.setOverride("PWD", "257 no quoted path\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +puts ftp.pwd.empty? +`) + if got != "true" { + t.Fatalf("pwd empty: %q", got) + } +} + +func TestFTPMtimeInvalid(t *testing.T) { + s := newFTPServer(t) + s.setOverride("MDTM", "213 not-a-time\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.mtime("x") +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("mtime invalid: %q", got) + } +} + +func TestFTPMlstErrors(t *testing.T) { + s := newFTPServer(t) + // Not a 250 reply -> FTPReplyError. + s.setOverride("MLST", "200 not listing\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPReplyError + puts "reply" +end`); got != "reply" { + t.Fatalf("mlst reply: %q", got) + } + // 250 single-line (no entry line) -> FTPProtoError. + s.setOverride("MLST", "250 done\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPProtoError + puts "proto" +end`); got != "proto" { + t.Fatalf("mlst proto: %q", got) + } + // 250 with a malformed entry line (no space -> no pathname) -> FTPProtoError. + s.setOverride("MLST", "250-list\r\nbadentrynopath\r\n250 End\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlst("x") +rescue Net::FTPProtoError + puts "bad" +end`); got != "bad" { + t.Fatalf("mlst bad entry: %q", got) + } + s.clearOverride("MLST") +} + +func TestFTPMlsdParseError(t *testing.T) { + s := newFTPServer(t) + // A data line with no space has no pathname -> ParseMLSxEntry FTPProtoError. + // First line is malformed (no space -> no pathname); a following valid line + // then hits the "already errored" short-circuit before the raise. + s.setDataOverride("MLSD", "type=file;size=1;nopath\r\ntype=file; ok\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.mlsd +rescue Net::FTPProtoError + puts "proto" +end +`) + if got != "proto" { + t.Fatalf("mlsd parse error: %q", got) + } + s.clearDataOverride("MLSD") +} + +func TestFTPTransferCmdErrorReleasesData(t *testing.T) { + s := newFTPServer(t) + // A 5yz reply to a transfer command (after PASV opened a data listener) + // re-raises and the binding closes the dialed data socket (no leak). + s.setOverride("NLST", "550 denied\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPPermError + puts "perm" +end`); got != "perm" { + t.Fatalf("transfer perm: %q", got) + } + // A stray 3yz reply is not a mark -> FTPReplyError. + s.setOverride("NLST", "350 hmm\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPReplyError + puts "reply" +end`); got != "reply" { + t.Fatalf("transfer mark: %q", got) + } + // Same error in active mode closes the listener (active cleanup path). + s.setOverride("NLST", "550 denied\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login + ftp.passive = false + ftp.nlst +rescue Net::FTPPermError + puts "active-perm" +end`); got != "active-perm" { + t.Fatalf("active transfer perm: %q", got) + } + s.clearOverride("NLST") +} + +func TestFTPCRLFRejection(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.sendcmd("FOO\nBAR") +rescue ArgumentError + puts "argerr" +end +`) + if got != "argerr" { + t.Fatalf("crlf rejection: %q", got) + } +} + +func TestFTPArityErrors(t *testing.T) { + s := newFTPServer(t) + for _, body := range []string{ + `ftp.connect`, + `ftp.chdir`, + `ftp.mkdir("a", "b")`, + `ftp.rename("a")`, + `ftp.size`, + `ftp.sendcmd`, + `ftp.retrlines`, + `ftp.storlines("STOR x")`, + `ftp.getbinaryfile`, + } { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + %s +rescue ArgumentError + puts "argerr" +end +`, body)) + if got != "argerr" { + t.Fatalf("arity %q: got %q", body, got) + } + } +} + +func TestFTPGetPutFileErrors(t *testing.T) { + s := newFTPServer(t) + // putbinaryfile / puttextfile of a nonexistent local file -> Errno::ENOENT. + for _, m := range []string{"putbinaryfile", "puttextfile"} { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.%s("/no/such/file/at/all", "r") +rescue Errno::ENOENT + puts "enoent" +end +`, m)) + if got != "enoent" { + t.Fatalf("%s enoent: %q", m, got) + } + } + // getbinaryfile / gettextfile to an unwritable local path -> Errno::EACCES. + bad := filepath.Join(t.TempDir(), "nonexistent-dir", "out") + for _, m := range []string{"getbinaryfile", "gettextfile"} { + got := s.run(t, fmt.Sprintf(` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.%s("file.txt", %q) +rescue Errno::EACCES + puts "eacces" +end +`, m, bad)) + if got != "eacces" { + t.Fatalf("%s eacces: %q", m, got) + } + } +} + +// --- tests: direct helper units --------------------------------------------- + +func TestFTPIsIPv6(t *testing.T) { + v4 := &fakeConn{remote: &net.TCPAddr{IP: net.IPv4(1, 2, 3, 4), Port: 1}} + v6 := &fakeConn{remote: &net.TCPAddr{IP: net.ParseIP("::1"), Port: 1}} + other := &fakeConn{remote: fakeAddr("no-port-here")} + if ftpIsIPv6(v4) { + t.Fatal("ipv4 reported as ipv6") + } + if !ftpIsIPv6(v6) { + t.Fatal("ipv6 not detected") + } + if ftpIsIPv6(other) { + t.Fatal("non-tcp addr reported as ipv6") + } +} + +func TestFTPHostHelpersMalformedAddr(t *testing.T) { + // A portless address exercises the SplitHostPort error fallback (whole string). + c := &fakeConn{remote: fakeAddr("hostonly"), local: fakeAddr("localonly")} + f := &ftpObj{conn: c} + if h := f.remoteHost(); h != "hostonly" { + t.Fatalf("remoteHost fallback = %q", h) + } + if h := f.localHost(); h != "localonly" { + t.Fatalf("localHost fallback = %q", h) + } + // And the normal split path. + ok := &fakeConn{remote: fakeAddr("1.2.3.4:5"), local: fakeAddr("6.7.8.9:10")} + f2 := &ftpObj{conn: ok} + if h := f2.remoteHost(); h != "1.2.3.4" { + t.Fatalf("remoteHost = %q", h) + } + if h := f2.localHost(); h != "6.7.8.9" { + t.Fatalf("localHost = %q", h) + } +} + +func TestFTPTextHelpers(t *testing.T) { + if got := ftpChomp("a\r\n"); got != "a" { + t.Fatalf("chomp crlf: %q", got) + } + if got := ftpChomp("a\n"); got != "a" { + t.Fatalf("chomp lf: %q", got) + } + if got := ftpChomp("a\r"); got != "a" { + t.Fatalf("chomp cr: %q", got) + } + if got := ftpChomp("a"); got != "a" { + t.Fatalf("chomp none: %q", got) + } + if got := ftpChomp(""); got != "" { + t.Fatalf("chomp empty: %q", got) + } + if got := ftpToCRLF("a\nb\r\nc\n"); got != "a\r\nb\r\nc\r\n" { + t.Fatalf("toCRLF: %q", got) + } + if got := ftpBasename("/a/b/c.txt"); got != "c.txt" { + t.Fatalf("basename path: %q", got) + } + if got := ftpBasename("c.txt"); got != "c.txt" { + t.Fatalf("basename bare: %q", got) + } +} + +func TestFTPInspectTruthyAndClose(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +p ftp +puts ftp.to_s +puts(ftp ? "truthy" : "falsy") +e = ftp.mlsd.first +p e +puts e.to_s.start_with?("#\ntrue\net\ntrue" + if got != want { + t.Fatalf("inspect/truthy got:\n%q\nwant:\n%q", got, want) + } +} + +func TestFTPUnconnectedClose(t *testing.T) { + got := runSrc(t, `require "net/ftp" +ftp = Net::FTP.new +ftp.close # conn is nil -> no-op +puts ftp.closed?`) + if got != "true" { + t.Fatalf("unconnected close: %q", got) + } +} + +func TestFTPVoidRespNon2(t *testing.T) { + s := newFTPServer(t) + s.setOverride("NOOP", "350 not a 2yz\r\n") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.noop +rescue Net::FTPReplyError + puts "reply" +end`) + if got != "reply" { + t.Fatalf("voidresp non-2: %q", got) + } +} + +func TestFTPLoginErrorLadders(t *testing.T) { + s := newFTPServer(t) + // USER reply is a 1yz (passes getResp) but not 2/3yz -> LoginNext error. + s.setOverride("USER", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p") +rescue Net::FTPReplyError + puts "user" +end`); got != "user" { + t.Fatalf("user ladder: %q", got) + } + s.clearOverride("USER") + // PASS reply is a 1yz -> LoginNext(after PASS) error. + s.setOverride("PASS", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p") +rescue Net::FTPReplyError + puts "pass" +end`); got != "pass" { + t.Fatalf("pass ladder: %q", got) + } + s.clearOverride("PASS") + // ACCT reply is a 1yz (not 2yz) -> account step FTPReplyError. + s.setOverride("PASS", "332 need account\r\n") + s.setOverride("ACCT", "150 odd\r\n") + if got := s.run(t, ` +begin + Net::FTP.new("127.0.0.1", port: PORT).login("u", "p", "acct") +rescue Net::FTPReplyError + puts "acct" +end`); got != "acct" { + t.Fatalf("acct ladder: %q", got) + } + s.clearOverride("PASS") + s.clearOverride("ACCT") +} + +func TestFTPMakepasvParseErrors(t *testing.T) { + s := newFTPServer(t) + // Malformed 227 -> FTPProtoError (Parse227). + s.setOverride("PASV", "227 no address here\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPProtoError + puts "pasv" +end`); got != "pasv" { + t.Fatalf("pasv parse: %q", got) + } + s.clearOverride("PASV") + // Malformed 229 -> FTPProtoError (Parse229), driven via the EPSV path. + s.setOverride("EPSV", "229 no delimiters\r\n") + f := dialRaw(t, s) + f.epsv = true + expectRubyError(t, "Net::FTPProtoError", func() { + f.retrlines(netftp.NlstCommand(""), func(string) {}) + }) + f.doClose() + s.clearOverride("EPSV") +} + +func TestFTPTransferReadError(t *testing.T) { + s := newFTPServer(t) + // Server closes the control connection while replying to the transfer command. + s.setOverride("NLST", "@close") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.nlst +rescue Net::FTPConnectionError + puts "conn" +end`) + if got != "conn" { + t.Fatalf("transfer read error: %q", got) + } + s.clearOverride("NLST") +} + +func TestFTPChdirReadError(t *testing.T) { + s := newFTPServer(t) + s.setOverride("CDUP", "@close") + got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.chdir("..") +rescue Net::FTPConnectionError + puts "conn" +end`) + if got != "conn" { + t.Fatalf("chdir read error: %q", got) + } + s.clearOverride("CDUP") +} + +func TestFTPAbortErrors(t *testing.T) { + s := newFTPServer(t) + // A reply the codes MRI accepts do not include -> FTPProtoError. + s.setOverride("ABOR", "500 bad\r\n") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.abort +rescue Net::FTPProtoError + puts "proto" +end`); got != "proto" { + t.Fatalf("abort proto: %q", got) + } + // Server closes before replying to ABOR -> FTPConnectionError. + s.setOverride("ABOR", "@close") + if got := s.run(t, ` +begin + ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login; ftp.abort +rescue Net::FTPConnectionError + puts "conn" +end`); got != "conn" { + t.Fatalf("abort conn: %q", got) + } + s.clearOverride("ABOR") +} + +func TestFTPOptionAndAliasCoverage(t *testing.T) { + s := newFTPServer(t) + // String-keyed options hash, the ls/dir/getdir aliases, and the setters' + // return values. + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", {"port" => PORT}); ftp.login +p ftp.ls.size +p ftp.dir.size +puts ftp.getdir +puts(ftp.binary = false) +puts ftp.binary +puts(ftp.use_pasv_ip = true) +ftp.nlst(nil) # explicit nil pathname -> ftpOptString nil branch +ftp.retrbinary("RETR x", 0) { |c| } # invalid blocksize -> default +puts "ok" +`) + if !strings.Contains(got, "ok") || !strings.HasPrefix(got, "2\n2\n/\nfalse\nfalse\ntrue\n") { + t.Fatalf("option/alias coverage: %q", got) + } +} + +func TestFTPAccountAndPassiveOptions(t *testing.T) { + s := newFTPServer(t) + s.setOverride("PASS", "332 need account\r\n") + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT, passive: false, username: "u", password: "p", account: "acct") +puts ftp.passive +p ftp.nlst +`) + if !strings.HasPrefix(got, "false\n") || !strings.Contains(got, `["dir1", "file.txt"]`) { + t.Fatalf("account/passive options: %q", got) + } + s.clearOverride("PASS") +} + +func TestFTPStorlinesTypeError(t *testing.T) { + s := newFTPServer(t) + got := s.run(t, ` +ftp = Net::FTP.new("127.0.0.1", port: PORT); ftp.login +begin + ftp.storbinary("STOR x", 12345) +rescue TypeError + puts "typeerr" +end +`) + if got != "typeerr" { + t.Fatalf("stor type error: %q", got) + } +} + +// --- direct helper error-branch units --------------------------------------- + +// expectRubyError runs fn and asserts it raised the given Ruby exception class. +func expectRubyError(t *testing.T, class string, fn func()) { + t.Helper() + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected raise %s, got none", class) + } + re, ok := r.(RubyError) + if !ok { + t.Fatalf("expected RubyError, got %T: %v", r, r) + } + if re.Class != class { + t.Fatalf("expected %s, got %s", class, re.Class) + } + }() + fn() +} + +func TestFTPRaiseFallback(t *testing.T) { + // A non-FTPError (a bare transport error) maps to Net::FTPConnectionError. + expectRubyError(t, "Net::FTPConnectionError", func() { + ftpRaise(fmt.Errorf("boom")) + }) +} + +func TestFTPWriteLineError(t *testing.T) { + s := newFTPServer(t) + f := dialRaw(t, s) + f.conn.Close() // writing to a closed socket fails + expectRubyError(t, "Net::FTPConnectionError", func() { f.writeLine("NOOP") }) +} + +func TestFTPDialDataError(t *testing.T) { + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { + f.dialData("127.0.0.1", freePort(t)) // nothing is listening + }) +} + +func TestFTPAcceptDataError(t *testing.T) { + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + ln.Close() // accept on a closed listener fails + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { f.acceptData(ln) }) +} + +func TestFTPSendDataError(t *testing.T) { + c1, c2 := net.Pipe() + c2.Close() // the peer is gone, so writes fail + f := &ftpObj{} + expectRubyError(t, "Net::FTPConnectionError", func() { + f.sendData(c1, []byte("abcdef"), 2) // multiple chunks, then a write error + }) +} + +func TestFTPMakeportListenError(t *testing.T) { + // An unbindable local host makes net.Listen fail. + f := &ftpObj{conn: &fakeConn{local: fakeAddr("300.300.300.300:0")}} + expectRubyError(t, "Net::FTPConnectionError", func() { f.makeport() }) +} + +// freePort returns a port number with no listener bound to it. +func freePort(t *testing.T) int { + t.Helper() + ln, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + p := ln.Addr().(*net.TCPAddr).Port + ln.Close() + return p +} diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..74f28505 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -978,6 +978,10 @@ func (vm *VM) classOf(v object.Value) *RClass { return x.cls case *RedisBatch: return x.cls + case *ftpObj: + return x.cls + case *ftpMLSxEntry: + return x.cls case *PGConnObj: return x.cls case *PGResultObj: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..9018a06f 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,7 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, - "net/http": true, "net/https": true, "resolv": true, "singleton": true, + "net/http": true, "net/https": true, "net/ftp": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, "strscan": true, "fiber": true, "objspace": true, "csv": true,