diff --git a/go.mod b/go.mod index f56c9fd7..f1b78fb2 100644 --- a/go.mod +++ b/go.mod @@ -89,6 +89,7 @@ require ( github.com/go-ruby-mysql/mysql v0.0.0-20260704103258-b9ed4a15ba9d github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f + github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 github.com/go-ruby-observer/observer v0.0.0-20260630080708-c3a02da51f79 diff --git a/go.sum b/go.sum index a357f88a..3938f96e 100644 --- a/go.sum +++ b/go.sum @@ -340,6 +340,8 @@ github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7 h1:F+ghhcdLDBVGG github.com/go-ruby-nats/nats v0.0.0-20260704105415-42cce800b0e7/go.mod h1:cm0D4pmaTvvQFPK815E7/uJaGeGxkr5xFLHixeQixTA= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f h1:nS0qwCPtVAtp+48j6KkdTdpVL8jT9L2ZGQcJZwOKev8= github.com/go-ruby-net-http/net-http v0.0.0-20260630124155-c794366ce72f/go.mod h1:PiNQDuyrSPZX8OzApvgyn1fTjRNwx0KNO1vrzdAguEc= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7 h1:5aKzjgwxqqMvHLyQOJrDO8GLJUdE1izGJTLDG+3+V5k= +github.com/go-ruby-net-sftp/net-sftp v0.0.0-20260630142744-9be6f27056d7/go.mod h1:okvDFdQDwwx5ubNL6mmXTHGTutWhOTHSnUPq1JzhxoU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e h1:v8DyCc3Vp+REhN1uyQ4wN8CpJ6JMqWNOm9OESWFBtLU= github.com/go-ruby-nokogiri/nokogiri v0.0.0-20260702164556-6e939959240e/go.mod h1:WsLJ6/u8/nzHShZKChf20tHosn/nlVnOJiD/xEY6X8c= github.com/go-ruby-oauth2/oauth2 v0.0.0-20260702151234-88fab8d845a1 h1:iZFtIn5WHQRz/oa04qYAlLvTi0XDRP5dKPGpJTGRzKg= diff --git a/internal/vm/builtins.go b/internal/vm/builtins.go index 6351c32c..e670383f 100644 --- a/internal/vm/builtins.go +++ b/internal/vm/builtins.go @@ -425,6 +425,7 @@ func (vm *VM) bootstrap() { vm.registerSocket() // TCPSocket/TCPServer (net) + OpenSSL::SSL::SSLSocket (crypto/tls); after registerOpenSSL (upgrades its SSL shell) vm.registerNetHTTP() // net/http + net/https loadable shell; needs StandardError vm.registerNetHTTPTransport() // real Net::HTTP over the socket transport; after registerNetHTTP + registerSocket + vm.registerNetSFTP() // Net::SFTP client (require "net/sftp"), backed by go-ruby-net-sftp codec; SSH channel = injected IO seam; nests under Net, after registerNetHTTP vm.registerResolv() // Resolv (real IPv4/IPv6 parse; DNS sockets stubbed); needs StandardError vm.registerTimeout() // Timeout module (loadable shell); needs RuntimeError vm.registerDateErrors() // Date::Error < ArgumentError (Date class itself registered early); needs ArgumentError diff --git a/internal/vm/net_sftp.go b/internal/vm/net_sftp.go new file mode 100644 index 00000000..07452842 --- /dev/null +++ b/internal/vm/net_sftp.go @@ -0,0 +1,356 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + sftp "github.com/go-ruby-net-sftp/net-sftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file binds the pure-Go SFTP wire codec github.com/go-ruby-net-sftp/net-sftp +// into rbgo as require "net/sftp" — the Ruby Net::SFTP surface (Session#open!/ +// read!/write!/stat!/…, upload!/download!, the dir and file operation helpers). +// +// SFTP runs over an SSH channel. The library owns only the deterministic packet +// codec (framing, the version-aware ATTRS struct, request-id correlation, version +// negotiation); the encrypted SSH transport is the host's job — the "channel +// seam". rbgo has no go-ruby-ssh yet, so Net::SFTP.start takes an injected channel +// object responding to #write(bytes) / #read(n) (a duck-typed SSH channel, e.g. a +// StringIO in tests), exactly as Redis.new takes an injected IO connection. The +// binding drives the library's request builders and response parsers over that +// channel: it writes the FXP_INIT frame, negotiates the version from FXP_VERSION, +// then for each operation writes the request frame and reads the correlated +// response back. sftpChannel (net_sftp_bind.go) bridges the Ruby duck to the byte +// stream a PacketParser reassembles. + +// sftpReadChunk is the number of bytes the driver requests from the channel's +// #read per fill; the PacketParser reassembles whole packets from the chunks. +const sftpReadChunk = 32768 + +// sftpDownloadChunk is the per-FXP_READ request size used by download!/file read. +const sftpDownloadChunk = 32000 + +// sftpSession is a live Net::SFTP session: the injected channel plus the codec +// state (the negotiated protocol builder and the packet reassembler) the driver +// runs the request/response loop over. +type sftpSession struct { + cls *RClass + vm *VM + channel object.Value // the Ruby SSH-channel duck (#write / #read) + proto *sftp.Protocol + parser *sftp.PacketParser + version int +} + +func (s *sftpSession) ToS() string { return "#" } +func (s *sftpSession) Inspect() string { return "#" } +func (s *sftpSession) Truthy() bool { return true } + +// sftpDir is the Session#dir helper (Net::SFTP::Operations::Dir): directory +// enumeration (foreach / entries / glob) over its owning session. +type sftpDir struct { + cls *RClass + s *sftpSession +} + +func (d *sftpDir) ToS() string { return "#" } +func (d *sftpDir) Inspect() string { return "#" } +func (d *sftpDir) Truthy() bool { return true } + +// sftpFileFactory is the Session#file helper (Net::SFTP::Operations::FileFactory): +// it opens remote files (#open) and answers metadata queries (#directory?). +type sftpFileFactory struct { + cls *RClass + s *sftpSession +} + +func (f *sftpFileFactory) ToS() string { return "#" } +func (f *sftpFileFactory) Inspect() string { return "#" } +func (f *sftpFileFactory) Truthy() bool { return true } + +// sftpFile is an open remote file (Net::SFTP::Operations::File): a handle plus a +// read/write cursor the #read / #write / #close methods advance. +type sftpFile struct { + cls *RClass + s *sftpSession + handle []byte + pos uint64 + closed bool +} + +func (f *sftpFile) ToS() string { return "#" } +func (f *sftpFile) Inspect() string { return "#" } +func (f *sftpFile) Truthy() bool { return true } + +// --- the channel-seam driver ----------------------------------------------- + +// writeFrame writes framed request bytes to the channel via the duck's #write. +func (s *sftpSession) writeFrame(frame []byte) { + s.vm.send(s.channel, "write", []object.Value{object.NewStringBytesEnc(append([]byte(nil), frame...), "ASCII-8BIT")}, nil) +} + +// nextPacket reassembles and returns the next complete packet, pulling more bytes +// from the channel's #read whenever the parser needs them. An empty read before a +// packet completes means the channel closed mid-stream. +func (s *sftpSession) nextPacket() sftp.Packet { + for { + if pkt, ok := s.parser.Next(); ok { + return pkt + } + rv := s.vm.send(s.channel, "read", []object.Value{object.IntValue(sftpReadChunk)}, nil) + data := sftpReadBytes(rv) + if len(data) == 0 { + raise("Net::SFTP::Exception", "the SFTP channel closed before a complete packet arrived") + } + s.parser.Feed(data) + } +} + +// connect performs the FXP_INIT / FXP_VERSION handshake: it advertises the +// client's highest version, parses the server's reply, and builds the negotiated +// request builder. It raises Net::SFTP::Exception on any protocol fault. +func (s *sftpSession) connect() { + s.writeFrame(sftp.InitPacket(sftp.HighestProtocolVersionSupported)) + pkt := s.nextPacket() + if pkt.Type != sftp.FXP_VERSION { + raise("Net::SFTP::Exception", "expected FXP_VERSION, got packet type %d", pkt.Type) + } + info, err := sftp.ParseVersion(pkt.Payload) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + v, err := sftp.NegotiateVersion(info.Version, sftp.HighestProtocolVersionSupported) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + s.version = v + s.proto = sftp.NewProtocol(v) +} + +// response writes a request frame and returns the parsed, id-correlated response. +func (s *sftpSession) response(id uint32, frame []byte) any { + s.writeFrame(frame) + pkt := s.nextPacket() + resp, err := sftp.ParseResponse(pkt, s.version) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + if got := sftpRespID(resp); got != id { + raise("Net::SFTP::Exception", "response id mismatch: got %d, expected %d", got, id) + } + return resp +} + +// okOrRaise interprets a status-only response: FX_OK returns nil, any other code +// raises the gem-faithful Net::SFTP::StatusException. +func (s *sftpSession) okOrRaise(resp any, op string) object.Value { + st := resp.(*sftp.StatusResponse) + if st.OK() { + return object.NilV + } + return s.raiseStatus(st, op) +} + +// handleOf interprets an open/opendir response: an FXP_HANDLE yields the handle +// string, an FXP_STATUS raises. +func (s *sftpSession) handleOf(resp any, op string) []byte { + if st, ok := resp.(*sftp.StatusResponse); ok { + s.raiseStatus(st, op) + } + return resp.(*sftp.HandleResponse).Handle +} + +// openHandle opens path in the given mode and returns the raw handle bytes. It +// translates the Ruby mode string into the protocol-version-specific open flags. +func (s *sftpSession) openHandle(path, mode string, attrs *sftp.Attributes) []byte { + flags, err := sftp.NormalizeOpenFlags(mode) + if err != nil { + raise("ArgumentError", "%s", err.Error()) + } + var id uint32 + var frame []byte + if s.version >= 5 { + sf, da := sftp.OpenFlagsV5(flags) + id, frame = s.proto.Open(path, sf, da, attrs) + } else { + id, frame = s.proto.Open(path, sftp.OpenFlagsV1(flags), 0, attrs) + } + return s.handleOf(s.response(id, frame), "open") +} + +// closeHandle closes an open file or directory handle. +func (s *sftpSession) closeHandle(h []byte) object.Value { + id, frame := s.proto.Close(h) + return s.okOrRaise(s.response(id, frame), "close") +} + +// readAt issues one FXP_READ and returns the data bytes, or ok=false at EOF. +func (s *sftpSession) readAt(h []byte, offset uint64, length uint32) (data []byte, ok bool) { + id, frame := s.proto.Read(h, offset, length) + resp := s.response(id, frame) + if st, isStatus := resp.(*sftp.StatusResponse); isStatus { + if st.EOF() { + return nil, false + } + s.raiseStatus(st, "read") + } + return resp.(*sftp.DataResponse).Data, true +} + +// readdirAll enumerates a directory handle, returning every Name entry. +func (s *sftpSession) readdirAll(h []byte) []sftp.Name { + var out []sftp.Name + for { + id, frame := s.proto.Readdir(h) + resp := s.response(id, frame) + if st, isStatus := resp.(*sftp.StatusResponse); isStatus { + if st.EOF() { + return out + } + s.raiseStatus(st, "readdir") + } + nr := resp.(*sftp.NameResponse) + out = append(out, nr.Names...) + } +} + +// --- registration ---------------------------------------------------------- + +// registerNetSFTP installs the Net::SFTP surface (require "net/sftp"): the +// Session client driven over an injected SSH channel, the dir/file operation +// helpers, the version-aware Attributes and Name value objects, the +// Net::SFTP::Constants tables, and the Exception / StatusException tree. It nests +// under the existing Net module, so it must run after registerNetHTTP (which +// creates Net) and after registerSocket / registerOpenSSL for consistency with +// the other injected-transport clients. +func (vm *VM) registerNetSFTP() { + net := vm.consts["Net"].(*RClass) + std := vm.consts["StandardError"].(*RClass) + + mod := newClass("Net::SFTP", vm.cObject) + mod.isModule = true + net.consts["SFTP"] = mod + vm.consts["Net::SFTP"] = mod + + // The exception tree: Net::SFTP::Exception < StandardError, and + // Net::SFTP::StatusException < Exception (carrying code / description / text). + exc := newClass("Net::SFTP::Exception", std) + mod.consts["Exception"] = exc + vm.consts["Net::SFTP::Exception"] = exc + statusExc := newClass("Net::SFTP::StatusException", exc) + mod.consts["StatusException"] = statusExc + vm.consts["Net::SFTP::StatusException"] = statusExc + vm.defineStatusExceptionMethods(statusExc) + + vm.registerNetSFTPConstants(mod) + + // The value classes returned by stat!/readdir!/dir.entries. + attrsCls := vm.registerNetSFTPAttributes(mod) + nameCls := newClass("Net::SFTP::Name", vm.cObject) + mod.consts["Name"] = nameCls + vm.consts["Net::SFTP::Name"] = nameCls + vm.defineNetSFTPNameMethods(nameCls) + + session := newClass("Net::SFTP::Session", vm.cObject) + mod.consts["Session"] = session + vm.consts["Net::SFTP::Session"] = session + session.consts["HIGHEST_PROTOCOL_VERSION_SUPPORTED"] = object.IntValue(int64(sftp.HighestProtocolVersionSupported)) + + dirCls := newClass("Net::SFTP::Operations::Dir", vm.cObject) + vm.consts["Net::SFTP::Operations::Dir"] = dirCls + fileFactoryCls := newClass("Net::SFTP::Operations::FileFactory", vm.cObject) + vm.consts["Net::SFTP::Operations::FileFactory"] = fileFactoryCls + fileCls := newClass("Net::SFTP::Operations::File", vm.cObject) + vm.consts["Net::SFTP::Operations::File"] = fileCls + + vm.sftpClasses = &sftpClassSet{ + attrs: attrsCls, + name: nameCls, + statusExc: statusExc, + dir: dirCls, + fileFactory: fileFactoryCls, + file: fileCls, + } + + // Net::SFTP.start(host, user, options={}) { |sftp| ... } and + // Net::SFTP::Session.new(channel) both build a connected session over the + // injected channel. + starter := func(_ *VM, _ object.Value, args []object.Value, blk *Proc) object.Value { + return vm.newSFTPSession(session, args, blk) + } + mod.smethods["start"] = &Method{name: "start", owner: mod, native: starter} + session.smethods["new"] = &Method{name: "new", owner: session, native: func(_ *VM, _ object.Value, args []object.Value, _ *Proc) object.Value { + return vm.newSFTPSession(session, args, nil) + }} + + vm.defineNetSFTPSessionMethods(session) + vm.defineNetSFTPDirMethods(dirCls) + vm.defineNetSFTPFileFactoryMethods(fileFactoryCls) + vm.defineNetSFTPFileMethods(fileCls) +} + +// registerNetSFTPConstants installs Net::SFTP::Constants: the StatusCodes and +// PacketTypes tables plus HighestProtocolVersionSupported, mirroring the gem's +// Net::SFTP::Constants module so a script can reference FX_* / FXP_* by name. +func (vm *VM) registerNetSFTPConstants(mod *RClass) { + c := newClass("Net::SFTP::Constants", vm.cObject) + c.isModule = true + mod.consts["Constants"] = c + vm.consts["Net::SFTP::Constants"] = c + + status := newClass("Net::SFTP::Constants::StatusCodes", vm.cObject) + status.isModule = true + c.consts["StatusCodes"] = status + for name, val := range map[string]int{ + "FX_OK": sftp.FX_OK, "FX_EOF": sftp.FX_EOF, "FX_NO_SUCH_FILE": sftp.FX_NO_SUCH_FILE, + "FX_PERMISSION_DENIED": sftp.FX_PERMISSION_DENIED, "FX_FAILURE": sftp.FX_FAILURE, + "FX_BAD_MESSAGE": sftp.FX_BAD_MESSAGE, "FX_NO_CONNECTION": sftp.FX_NO_CONNECTION, + "FX_CONNECTION_LOST": sftp.FX_CONNECTION_LOST, "FX_OP_UNSUPPORTED": sftp.FX_OP_UNSUPPORTED, + } { + status.consts[name] = object.IntValue(int64(val)) + } + + types := newClass("Net::SFTP::Constants::PacketTypes", vm.cObject) + types.isModule = true + c.consts["PacketTypes"] = types + for name, val := range map[string]int{ + "FXP_INIT": sftp.FXP_INIT, "FXP_VERSION": sftp.FXP_VERSION, "FXP_OPEN": sftp.FXP_OPEN, + "FXP_CLOSE": sftp.FXP_CLOSE, "FXP_READ": sftp.FXP_READ, "FXP_WRITE": sftp.FXP_WRITE, + "FXP_STATUS": sftp.FXP_STATUS, "FXP_HANDLE": sftp.FXP_HANDLE, "FXP_DATA": sftp.FXP_DATA, + "FXP_NAME": sftp.FXP_NAME, "FXP_ATTRS": sftp.FXP_ATTRS, + } { + types.consts[name] = object.IntValue(int64(val)) + } +} + +// sftpClassSet caches the wrapper classes so the value-mapping helpers can stamp +// the right Ruby class on a returned Attributes / Name / File without threading +// the VM's const table through every call. +type sftpClassSet struct { + attrs *RClass + name *RClass + statusExc *RClass + dir *RClass + fileFactory *RClass + file *RClass +} + +// newSFTPSession builds a session over the injected channel, runs the version +// handshake, and (for start with a block) yields the session to the block. The +// channel is the first positional non-Hash argument or the channel: / connection: +// keyword; host / user positionals are accepted and ignored (there is no SSH). +func (vm *VM) newSFTPSession(cls *RClass, args []object.Value, blk *Proc) object.Value { + ch := sftpChannelArg(args) + if ch == nil { + raise("ArgumentError", "Net::SFTP requires a channel: IO-like object responding to #read/#write (rbgo has no native SSH transport yet)") + } + s := &sftpSession{cls: cls, vm: vm, channel: ch, parser: sftp.NewPacketParser()} + s.connect() + if blk != nil { + vm.callBlock(blk, []object.Value{s}) + } + return s +} diff --git a/internal/vm/net_sftp_bind.go b/internal/vm/net_sftp_bind.go new file mode 100644 index 00000000..3654d0c2 --- /dev/null +++ b/internal/vm/net_sftp_bind.go @@ -0,0 +1,737 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "time" + + sftp "github.com/go-ruby-net-sftp/net-sftp" + + "github.com/go-embedded-ruby/ruby/internal/object" +) + +// This file is the value-mapping half of the Net::SFTP binding: the channel-seam +// helpers, the Ruby argument decoding, the Attributes / Name value objects, the +// StatusException raise, and the Session / dir / file method surfaces. net_sftp.go +// owns the driver and registration; here every Ruby method closure maps its +// arguments onto the session's driver and its reply back into the object graph. + +// --- channel seam ---------------------------------------------------------- + +// sftpReadBytes extracts the byte payload a Ruby channel #read returned: a String +// yields its bytes, anything else (nil at EOF) yields none. +func sftpReadBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + return nil +} + +// sftpChannelArg resolves the injected SSH-channel object from the call +// arguments: the channel: / connection: keyword of a trailing Hash (the +// Net::SFTP.start form, where the positionals are host / user strings), else the +// first non-String positional (the Session.new(channel) form). String positionals +// are the ignored host / user; a Hash is the keyword set. Returns nil when no +// channel is supplied. +func sftpChannelArg(args []object.Value) object.Value { + if len(args) > 0 { + if h, ok := args[len(args)-1].(*object.Hash); ok { + if v, ok := h.Get(object.Symbol("channel")); ok { + return v + } + if v, ok := h.Get(object.NewString("channel")); ok { + return v + } + if v, ok := h.Get(object.Symbol("connection")); ok { + return v + } + if v, ok := h.Get(object.NewString("connection")); ok { + return v + } + args = args[:len(args)-1] + } + } + for _, a := range args { + if _, isStr := a.(*object.String); isStr { + continue + } + if _, isHash := a.(*object.Hash); isHash { + continue + } + return a + } + return nil +} + +// --- argument helpers ------------------------------------------------------ + +// sftpBytes returns the raw bytes of a Ruby String argument (a handle or data +// payload), raising TypeError otherwise. +func sftpBytes(v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + raise("TypeError", "expected a String") + return nil +} + +// sftpStr returns the contents of a Ruby String argument, raising TypeError +// otherwise. +func sftpStr(v object.Value) string { + if s, ok := v.(*object.String); ok { + return s.Str() + } + raise("TypeError", "expected a String") + return "" +} + +// sftpUint64 coerces a Ruby Integer to uint64, raising TypeError otherwise. +func sftpUint64(v object.Value) uint64 { + if n, ok := v.(object.Integer); ok { + return uint64(int64(n)) + } + raise("TypeError", "expected an Integer") + return 0 +} + +// sftpUint32 coerces a Ruby Integer to uint32, raising TypeError otherwise. +func sftpUint32(v object.Value) uint32 { + if n, ok := v.(object.Integer); ok { + return uint32(int64(n)) + } + raise("TypeError", "expected an Integer") + return 0 +} + +// sftpArg returns args[i] or nil when the slice is shorter, so optional trailing +// arguments (flags, mode, attribute Hash) collapse to their defaults. +func sftpArg(args []object.Value, i int) object.Value { + if i < len(args) { + return args[i] + } + return nil +} + +// sftpModeArg returns the open-mode string of an optional argument, defaulting to +// "r" when absent or nil. +func sftpModeArg(v object.Value) string { + if v == nil || v == object.NilV { + return "r" + } + return sftpStr(v) +} + +// sftpAttrsArg builds an Attributes value from an optional Ruby Hash of +// attribute keywords, or an empty structure when absent. +func sftpAttrsArg(v object.Value) *sftp.Attributes { + a := &sftp.Attributes{} + h, ok := v.(*object.Hash) + if !ok { + return a + } + get := func(name string) (object.Value, bool) { + if hv, ok := h.Get(object.Symbol(name)); ok { + return hv, true + } + return h.Get(object.NewString(name)) + } + if v, ok := get("size"); ok { + n := sftpUint64(v) + a.Size = &n + } + if v, ok := get("permissions"); ok { + n := sftpUint32(v) + a.Permissions = &n + } + if v, ok := get("uid"); ok { + n := sftpUint32(v) + a.UID = &n + } + if v, ok := get("gid"); ok { + n := sftpUint32(v) + a.GID = &n + } + if v, ok := get("owner"); ok { + s := sftpStr(v) + a.Owner = &s + } + if v, ok := get("group"); ok { + s := sftpStr(v) + a.Group = &s + } + if v, ok := get("atime"); ok { + n := sftpUint64(v) + a.Atime = &n + } + if v, ok := get("mtime"); ok { + n := sftpUint64(v) + a.Mtime = &n + } + return a +} + +// --- response id + status -------------------------------------------------- + +// sftpRespID returns the echoed request id carried by any parsed response, so the +// driver can correlate it to the request. +func sftpRespID(resp any) uint32 { + switch r := resp.(type) { + case *sftp.StatusResponse: + return r.ID + case *sftp.HandleResponse: + return r.ID + case *sftp.DataResponse: + return r.ID + case *sftp.NameResponse: + return r.ID + default: + return resp.(*sftp.AttrsResponse).ID + } +} + +// raiseStatus builds and raises the gem-faithful Net::SFTP::StatusException from a +// non-success FXP_STATUS, carrying its code / description / text. It never returns. +func (s *sftpSession) raiseStatus(st *sftp.StatusResponse, op string) object.Value { + se := sftp.NewStatusException(st, op) + obj := &RObject{class: s.vm.sftpClasses.statusExc, ivars: map[string]object.Value{ + "@code": object.IntValue(int64(se.Code)), + "@description": object.NewString(se.Description), + "@text": object.NewString(se.Text), + "@message": object.NewString(se.Error()), + }} + panic(s.vm.excError(obj)) +} + +// defineStatusExceptionMethods installs code / description / text / message / +// to_s on Net::SFTP::StatusException, each reading the ivar set at raise time. +func (vm *VM) defineStatusExceptionMethods(cls *RClass) { + cls.define("code", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@code") + }) + cls.define("description", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@description") + }) + cls.define("text", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@text") + }) + msg := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return getIvar(self, "@message") + } + cls.define("message", msg) + cls.define("to_s", msg) +} + +// --- value objects: Attributes --------------------------------------------- + +// sftpAttrs wraps a decoded *sftp.Attributes as a Ruby +// Net::SFTP::Protocol::V01::Attributes value. +type sftpAttrs struct { + cls *RClass + a *sftp.Attributes +} + +func (a *sftpAttrs) ToS() string { return "#" } +func (a *sftpAttrs) Inspect() string { return "#" } +func (a *sftpAttrs) Truthy() bool { return true } + +// newAttrs stamps the Attributes wrapper class on a decoded attribute structure. +func (s *sftpSession) newAttrs(a *sftp.Attributes) object.Value { + return &sftpAttrs{cls: s.vm.sftpClasses.attrs, a: a} +} + +// optUint64 maps an optional 64-bit field to a Ruby Integer or nil. +func optUint64(p *uint64) object.Value { + if p == nil { + return object.NilV + } + return object.IntValue(int64(*p)) +} + +// optUint32 maps an optional 32-bit field to a Ruby Integer or nil. +func optUint32(p *uint32) object.Value { + if p == nil { + return object.NilV + } + return object.IntValue(int64(*p)) +} + +// optString maps an optional string field to a Ruby String or nil. +func optString(p *string) object.Value { + if p == nil { + return object.NilV + } + return object.NewString(*p) +} + +// registerNetSFTPAttributes installs the Attributes value class and its accessors +// (size/uid/gid/owner/group/permissions/atime/mtime plus the type predicates). +func (vm *VM) registerNetSFTPAttributes(mod *RClass) *RClass { + cls := newClass("Net::SFTP::Protocol::V01::Attributes", vm.cObject) + vm.consts["Net::SFTP::Protocol::V01::Attributes"] = cls + mod.consts["Attributes"] = cls + at := func(v object.Value) *sftp.Attributes { return v.(*sftpAttrs).a } + cls.define("size", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Size) + }) + cls.define("uid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).UID) + }) + cls.define("gid", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).GID) + }) + cls.define("owner", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optString(at(self).Owner) + }) + cls.define("group", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optString(at(self).Group) + }) + cls.define("permissions", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint32(at(self).Permissions) + }) + cls.define("atime", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Atime) + }) + cls.define("mtime", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return optUint64(at(self).Mtime) + }) + cls.define("type", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(at(self).FileType())) + }) + cls.define("directory?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsDirectory() + return object.Bool(val) + }) + cls.define("symlink?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsSymlink() + return object.Bool(val) + }) + cls.define("file?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := at(self).IsFile() + return object.Bool(val) + }) + return cls +} + +// --- value objects: Name --------------------------------------------------- + +// sftpName wraps one FXP_NAME entry as a Ruby Net::SFTP::Name value. +type sftpName struct { + cls *RClass + n sftp.Name + version int +} + +func (n *sftpName) ToS() string { return "#" } +func (n *sftpName) Inspect() string { return "#" } +func (n *sftpName) Truthy() bool { return true } + +// newName stamps the Name wrapper class on a directory entry. +func (s *sftpSession) newName(n sftp.Name) object.Value { + return &sftpName{cls: s.vm.sftpClasses.name, n: n, version: s.version} +} + +// defineNetSFTPNameMethods installs the Name accessors (name/filename/longname/ +// attributes plus the type predicates). +func (vm *VM) defineNetSFTPNameMethods(cls *RClass) { + nm := func(v object.Value) *sftpName { return v.(*sftpName) } + name := func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.NewString(nm(self).n.Filename) + } + cls.define("name", name) + cls.define("filename", name) + cls.define("longname", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + e := nm(self) + if e.n.Longname != "" { + return object.NewString(e.n.Longname) + } + return object.NewString(e.n.LongnameFor(time.Local)) + }) + cls.define("attributes", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpAttrs{cls: vm.sftpClasses.attrs, a: nm(self).n.Attributes} + }) + cls.define("directory?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsDirectory() + return object.Bool(val) + }) + cls.define("symlink?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsSymlink() + return object.Bool(val) + }) + cls.define("file?", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + val, _ := nm(self).n.IsFile() + return object.Bool(val) + }) +} + +// --- Session methods ------------------------------------------------------- + +// asSession unwraps a Session receiver. +func asSession(v object.Value) *sftpSession { return v.(*sftpSession) } + +// defineNetSFTPSessionMethods installs the full Session request surface: the +// version accessor, the primitive bang-methods (open!/close!/read!/write!/…), the +// higher-level upload!/download!, and the dir / file operation-helper accessors. +func (vm *VM) defineNetSFTPSessionMethods(cls *RClass) { + cls.define("protocol_version", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(asSession(self).version)) + }) + cls.define("version", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(asSession(self).version)) + }) + + cls.define("open!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + path := sftpStr(sftpArg(args, 0)) + attrs := sftpAttrsArg(sftpArg(args, 2)) + h := s.openHandle(path, sftpModeArg(sftpArg(args, 1)), attrs) + return object.NewStringBytesEnc(append([]byte(nil), h...), "ASCII-8BIT") + }) + cls.define("close!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return asSession(self).closeHandle(sftpBytes(sftpArg(args, 0))) + }) + cls.define("read!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + off := sftpUint64(sftpArg(args, 1)) + length := sftpUint32(sftpArg(args, 2)) + data, ok := s.readAt(h, off, length) + if !ok { + return object.NilV + } + return object.NewStringBytesEnc(append([]byte(nil), data...), "ASCII-8BIT") + }) + cls.define("write!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + off := sftpUint64(sftpArg(args, 1)) + data := sftpBytes(sftpArg(args, 2)) + id, frame := s.proto.Write(h, off, data) + return s.okOrRaise(s.response(id, frame), "write") + }) + + vm.defineNetSFTPStatMethods(cls) + vm.defineNetSFTPDirEntryMethods(cls) + vm.defineNetSFTPLinkMethods(cls) + vm.defineNetSFTPTransferMethods(cls) + + cls.define("dir", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpDir{cls: vm.sftpClasses.dir, s: asSession(self)} + }) + cls.define("file", func(vm *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return &sftpFileFactory{cls: vm.sftpClasses.fileFactory, s: asSession(self)} + }) +} + +// defineNetSFTPStatMethods installs stat!/lstat!/fstat! and setstat!/fsetstat!. +func (vm *VM) defineNetSFTPStatMethods(cls *RClass) { + statPath := func(self object.Value, args []object.Value, op string, build func(*sftpSession, string) (uint32, []byte)) object.Value { + s := asSession(self) + id, frame := build(s, sftpStr(sftpArg(args, 0))) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, op) + } + return s.newAttrs(resp.(*sftp.AttrsResponse).Attrs) + } + cls.define("stat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return statPath(self, args, "stat", func(s *sftpSession, p string) (uint32, []byte) { return s.proto.Stat(p, nil) }) + }) + cls.define("lstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + return statPath(self, args, "lstat", func(s *sftpSession, p string) (uint32, []byte) { return s.proto.Lstat(p, nil) }) + }) + cls.define("fstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Fstat(sftpBytes(sftpArg(args, 0)), nil) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "fstat") + } + return s.newAttrs(resp.(*sftp.AttrsResponse).Attrs) + }) + cls.define("setstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Setstat(sftpStr(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "setstat") + }) + cls.define("fsetstat!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Fsetstat(sftpBytes(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "fsetstat") + }) +} + +// defineNetSFTPDirEntryMethods installs opendir!/readdir!/mkdir!/rmdir!/remove!/ +// realpath!. +func (vm *VM) defineNetSFTPDirEntryMethods(cls *RClass) { + cls.define("opendir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Opendir(sftpStr(sftpArg(args, 0))) + h := s.handleOf(s.response(id, frame), "opendir") + return object.NewStringBytesEnc(append([]byte(nil), h...), "ASCII-8BIT") + }) + cls.define("readdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := sftpBytes(sftpArg(args, 0)) + id, frame := s.proto.Readdir(h) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + if st.EOF() { + return object.NilV + } + return s.raiseStatus(st, "readdir") + } + nr := resp.(*sftp.NameResponse) + arr := object.NewArray() + for _, n := range nr.Names { + arr.Elems = append(arr.Elems, s.newName(n)) + } + return arr + }) + cls.define("remove!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Remove(sftpStr(sftpArg(args, 0))) + return s.okOrRaise(s.response(id, frame), "remove") + }) + cls.define("mkdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Mkdir(sftpStr(sftpArg(args, 0)), sftpAttrsArg(sftpArg(args, 1))) + return s.okOrRaise(s.response(id, frame), "mkdir") + }) + cls.define("rmdir!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Rmdir(sftpStr(sftpArg(args, 0))) + return s.okOrRaise(s.response(id, frame), "rmdir") + }) + cls.define("realpath!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame := s.proto.Realpath(sftpStr(sftpArg(args, 0))) + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "realpath") + } + return s.firstName(resp.(*sftp.NameResponse), "realpath") + }) +} + +// firstName returns the first Name entry of a single-entry FXP_NAME response +// (realpath/readlink), raising when the server returned none. +func (s *sftpSession) firstName(nr *sftp.NameResponse, op string) object.Value { + if len(nr.Names) == 0 { + raise("Net::SFTP::Exception", "%s returned no name entry", op) + } + return object.NewString(nr.Names[0].Filename) +} + +// defineNetSFTPLinkMethods installs rename!/readlink!/symlink!/link!/block!/ +// unblock! — the operations the library gates by protocol version. +func (vm *VM) defineNetSFTPLinkMethods(cls *RClass) { + cls.define("rename!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Rename(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1)), nil) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "rename") + }) + cls.define("readlink!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Readlink(sftpStr(sftpArg(args, 0))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + resp := s.response(id, frame) + if st, ok := resp.(*sftp.StatusResponse); ok { + return s.raiseStatus(st, "readlink") + } + return s.firstName(resp.(*sftp.NameResponse), "readlink") + }) + cls.define("symlink!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Symlink(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "symlink") + }) + cls.define("link!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + sym := sftpArg(args, 2) + symlink := !object.IsNil(sym) && sym.Truthy() + id, frame, err := s.proto.Link(sftpStr(sftpArg(args, 0)), sftpStr(sftpArg(args, 1)), symlink) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "link") + }) + cls.define("block!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Block(sftpBytes(sftpArg(args, 0)), sftpUint64(sftpArg(args, 1)), sftpUint64(sftpArg(args, 2)), sftpUint32(sftpArg(args, 3))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "block") + }) + cls.define("unblock!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + id, frame, err := s.proto.Unblock(sftpBytes(sftpArg(args, 0)), sftpUint64(sftpArg(args, 1)), sftpUint64(sftpArg(args, 2))) + if err != nil { + raise("Net::SFTP::Exception", "%s", err.Error()) + } + return s.okOrRaise(s.response(id, frame), "unblock") + }) +} + +// defineNetSFTPTransferMethods installs upload!/download! — whole-file transfers +// built over the open/read/write/close primitives. +func (vm *VM) defineNetSFTPTransferMethods(cls *RClass) { + cls.define("download!", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + h := s.openHandle(sftpStr(sftpArg(args, 0)), "r", &sftp.Attributes{}) + var buf []byte + var off uint64 + for { + data, ok := s.readAt(h, off, sftpDownloadChunk) + if !ok { + break + } + buf = append(buf, data...) + off += uint64(len(data)) + } + s.closeHandle(h) + return object.NewStringBytesEnc(buf, "ASCII-8BIT") + }) + cls.define("upload!", func(vm *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + s := asSession(self) + data := sftpUploadData(vm, sftpArg(args, 0)) + h := s.openHandle(sftpStr(sftpArg(args, 1)), "w", &sftp.Attributes{}) + id, frame := s.proto.Write(h, 0, data) + s.okOrRaise(s.response(id, frame), "write") + return s.closeHandle(h) + }) +} + +// sftpUploadData reads the upload source: a String is its bytes; any object +// responding to #read is drained via #read. +func sftpUploadData(vm *VM, v object.Value) []byte { + if s, ok := v.(*object.String); ok { + return s.Bytes() + } + rv := vm.send(v, "read", nil, nil) + return sftpBytes(rv) +} + +// --- dir operations -------------------------------------------------------- + +// defineNetSFTPDirMethods installs Net::SFTP::Operations::Dir#foreach / #entries / +// #glob over the owning session. +func (vm *VM) defineNetSFTPDirMethods(cls *RClass) { + entries := func(self object.Value, path string) []sftp.Name { + d := self.(*sftpDir) + id, frame := d.s.proto.Opendir(path) + h := d.s.handleOf(d.s.response(id, frame), "opendir") + names := d.s.readdirAll(h) + d.s.closeHandle(h) + return names + } + cls.define("entries", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + d := self.(*sftpDir) + arr := object.NewArray() + for _, n := range entries(self, sftpStr(sftpArg(args, 0))) { + arr.Elems = append(arr.Elems, d.s.newName(n)) + } + return arr + }) + cls.define("foreach", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + if blk == nil { + raise("LocalJumpError", "no block given (yield)") + } + d := self.(*sftpDir) + for _, n := range entries(self, sftpStr(sftpArg(args, 0))) { + vm.callBlock(blk, []object.Value{d.s.newName(n)}) + } + return object.NilV + }) +} + +// --- file operations ------------------------------------------------------- + +// defineNetSFTPFileFactoryMethods installs Net::SFTP::Operations::FileFactory#open +// and #directory?. +func (vm *VM) defineNetSFTPFileFactoryMethods(cls *RClass) { + cls.define("open", func(vm *VM, self object.Value, args []object.Value, blk *Proc) object.Value { + ff := self.(*sftpFileFactory) + path := sftpStr(sftpArg(args, 0)) + attrs := sftpAttrsArg(sftpArg(args, 2)) + h := ff.s.openHandle(path, sftpModeArg(sftpArg(args, 1)), attrs) + f := &sftpFile{cls: vm.sftpClasses.file, s: ff.s, handle: h} + if blk == nil { + return f + } + res := vm.callBlock(blk, []object.Value{f}) + f.doClose() + return res + }) + cls.define("directory?", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + ff := self.(*sftpFileFactory) + id, frame := ff.s.proto.Stat(sftpStr(sftpArg(args, 0)), nil) + resp := ff.s.response(id, frame) + if _, ok := resp.(*sftp.StatusResponse); ok { + return object.Bool(false) + } + val, _ := resp.(*sftp.AttrsResponse).Attrs.IsDirectory() + return object.Bool(val) + }) +} + +// doClose closes the file's handle once, tolerating a repeated #close. +func (f *sftpFile) doClose() object.Value { + if f.closed { + return object.NilV + } + f.closed = true + return f.s.closeHandle(f.handle) +} + +// defineNetSFTPFileMethods installs Net::SFTP::Operations::File#read / #write / +// #close / #pos / #eof?. +func (vm *VM) defineNetSFTPFileMethods(cls *RClass) { + fl := func(v object.Value) *sftpFile { return v.(*sftpFile) } + cls.define("read", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + f := fl(self) + if n := sftpArg(args, 0); n != nil && n != object.NilV { + data, ok := f.s.readAt(f.handle, f.pos, sftpUint32(n)) + if !ok { + return object.NilV + } + f.pos += uint64(len(data)) + return object.NewStringBytesEnc(append([]byte(nil), data...), "ASCII-8BIT") + } + // No length: read to EOF. + var buf []byte + for { + data, ok := f.s.readAt(f.handle, f.pos, sftpDownloadChunk) + if !ok { + break + } + buf = append(buf, data...) + f.pos += uint64(len(data)) + } + return object.NewStringBytesEnc(buf, "ASCII-8BIT") + }) + cls.define("write", func(_ *VM, self object.Value, args []object.Value, _ *Proc) object.Value { + f := fl(self) + data := sftpBytes(sftpArg(args, 0)) + id, frame := f.s.proto.Write(f.handle, f.pos, data) + f.s.okOrRaise(f.s.response(id, frame), "write") + f.pos += uint64(len(data)) + return object.IntValue(int64(len(data))) + }) + cls.define("pos", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return object.IntValue(int64(fl(self).pos)) + }) + cls.define("close", func(_ *VM, self object.Value, _ []object.Value, _ *Proc) object.Value { + return fl(self).doClose() + }) +} diff --git a/internal/vm/net_sftp_internal_test.go b/internal/vm/net_sftp_internal_test.go new file mode 100644 index 00000000..869ac89c --- /dev/null +++ b/internal/vm/net_sftp_internal_test.go @@ -0,0 +1,809 @@ +// Copyright (c) the go-embedded-ruby/ruby authors +// +// SPDX-License-Identifier: BSD-3-Clause + +package vm + +import ( + "fmt" + "strings" + "testing" + + sftp "github.com/go-ruby-net-sftp/net-sftp" +) + +// This is the internal test suite for the Net::SFTP binding (require "net/sftp"). +// SFTP runs over an SSH channel; rbgo injects that channel as a Ruby duck +// responding to #write / #read, so the tests drive the binding over an in-process +// canned channel (the Chan class) whose reply stream is built here with the very +// same wire codec the binding parses. Every response frame is produced by the +// go-ruby-net-sftp Writer / FramePacket, so the suite exercises the full driver +// (init/version negotiation, request framing, response correlation) without any +// SSH, network, or goroutine — the round-trip is fully deterministic. + +// --- wire-frame builders (server side) ------------------------------------- + +func sftpVersionFrame(v uint32) []byte { + w := sftp.NewWriter() + w.WriteUint32(v) + return sftp.FramePacket(sftp.FXP_VERSION, w.Bytes()) +} + +func sftpStatusFrame(id, code uint32, msg string, version int) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteUint32(code) + if version >= 3 { + w.WriteString(msg) + w.WriteString("") + } + return sftp.FramePacket(sftp.FXP_STATUS, w.Bytes()) +} + +func sftpHandleFrame(id uint32, handle []byte) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteBytes(handle) + return sftp.FramePacket(sftp.FXP_HANDLE, w.Bytes()) +} + +func sftpDataFrame(id uint32, data []byte) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteBytes(data) + return sftp.FramePacket(sftp.FXP_DATA, w.Bytes()) +} + +func sftpAttrsFrame(id uint32, a *sftp.Attributes, version int) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteRaw(a.Encode(version)) + return sftp.FramePacket(sftp.FXP_ATTRS, w.Bytes()) +} + +func sftpNameFrame(id uint32, version int, names []sftp.Name) []byte { + w := sftp.NewWriter() + w.WriteUint32(id) + w.WriteUint32(uint32(len(names))) + for _, n := range names { + w.WriteString(n.Filename) + if version < 4 { + w.WriteString(n.Longname) + } + w.WriteRaw(n.Attributes.Encode(version)) + } + return sftp.FramePacket(sftp.FXP_NAME, w.Bytes()) +} + +// u32p / u64p / u8p / strp build the optional-field pointers Attributes uses. +func u32p(v uint32) *uint32 { return &v } +func u64p(v uint64) *uint64 { return &v } + +// --- Ruby channel duck + driver harness ------------------------------------ + +// sftpChanClass is the injected SSH-channel duck: writes captured in @out, reads +// drain the canned reply buffer. @chunk caps each read so the packet-reassembly +// loop can be exercised. +const sftpChanClass = ` +class Chan + def initialize(reply, chunk = nil) + @in = reply.dup.force_encoding("ASCII-8BIT"); @pos = 0; @out = "".b; @chunk = chunk + end + def write(s) ; @out << s ; s.bytesize ; end + def read(n = nil) + avail = @in.bytesize - @pos + return "".b if avail <= 0 + n = avail if n.nil? || n > avail + n = @chunk if @chunk && @chunk < n + c = @in.byteslice(@pos, n) ; @pos += n ; c + end + def out ; @out ; end +end +` + +// rubyBin renders bytes as a Ruby ASCII-8BIT string literal. +func rubyBin(b []byte) string { + var sb strings.Builder + sb.WriteString(`"`) + for _, c := range b { + fmt.Fprintf(&sb, `\x%02x`, c) + } + sb.WriteString(`".b`) + return sb.String() +} + +// concat joins the version frame and each response frame into a reply stream. +func concat(serverVersion uint32, frames ...[]byte) []byte { + out := sftpVersionFrame(serverVersion) + for _, f := range frames { + out = append(out, f...) + } + return out +} + +// sftpSrc assembles a runnable script: the Chan class, require, a started session +// bound to `sftp`, then body. +func sftpSrc(serverVersion uint32, reply []byte, body string) string { + return sftpChanClass + "require \"net/sftp\"\n" + + "ch = Chan.new(" + rubyBin(reply) + ")\n" + + "sftp = Net::SFTP.start(\"host\", \"user\", channel: ch)\n" + body +} + +func sftpEval(t *testing.T, serverVersion uint32, reply []byte, body string) string { + t.Helper() + return eval(t, sftpSrc(serverVersion, reply, body)) +} + +func sftpErr(t *testing.T, serverVersion uint32, reply []byte, body string) (class, msg string) { + t.Helper() + return evalErr(t, sftpSrc(serverVersion, reply, body)) +} + +// --- loadability + surface ------------------------------------------------- + +func TestNetSFTPLoadable(t *testing.T) { + cases := []struct{ src, want string }{ + {`p require "net/sftp"`, "true\n"}, + {`require "net/sftp"; p require "net/sftp"`, "false\n"}, + {`require "net/sftp"; p Net::SFTP.is_a?(Module)`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::Session::HIGHEST_PROTOCOL_VERSION_SUPPORTED`, "6\n"}, + {`require "net/sftp"; p Net::SFTP::Exception < StandardError`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::StatusException < Net::SFTP::Exception`, "true\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::StatusCodes::FX_OK`, "0\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::StatusCodes::FX_NO_SUCH_FILE`, "2\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::PacketTypes::FXP_INIT`, "1\n"}, + {`require "net/sftp"; p Net::SFTP::Constants::PacketTypes::FXP_DATA`, "103\n"}, + } + for _, c := range cases { + if got := eval(t, c.src); got != c.want { + t.Errorf("src=%q got=%q want=%q", c.src, got, c.want) + } + } +} + +func TestNetSFTPConnectAndVersion(t *testing.T) { + // Server offers v6 -> negotiated min(6,6)=6. + if got := sftpEval(t, 6, concat(6), "p sftp.protocol_version\np sftp.version"); got != "6\n6\n" { + t.Errorf("v6 got=%q", got) + } + // Server offers v3 -> negotiated 3. + if got := sftpEval(t, 3, concat(3), "p sftp.protocol_version"); got != "3\n" { + t.Errorf("v3 got=%q", got) + } + // The FXP_INIT frame the client wrote advertises version 6. + got := sftpEval(t, 3, concat(3), `p ch.out.bytesize > 0`) + if got != "true\n" { + t.Errorf("init written got=%q", got) + } +} + +// --- channel resolution + start forms -------------------------------------- + +func TestNetSFTPChannelResolution(t *testing.T) { + reply := rubyBin(concat(3)) + // Session.new(channel) positional form. + src := sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new(Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("Session.new positional got=%q", got) + } + // A leading Hash positional is skipped, the channel is the next positional. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new({}, Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("Session.new hash-then-channel got=%q", got) + } + // connection: keyword (symbol). + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(connection: Chan.new(" + reply + ")).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("connection: symbol got=%q", got) + } + // String-keyed channel. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(\"h\", \"u\", {\"channel\" => Chan.new(" + reply + ")}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("string channel got=%q", got) + } + // String-keyed connection. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP.start(\"h\", \"u\", {\"connection\" => Chan.new(" + reply + ")}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("string connection got=%q", got) + } + // Trailing hash without a channel keyword -> stripped, positional scanned. + src = sftpChanClass + "require \"net/sftp\"\np Net::SFTP::Session.new(Chan.new(" + reply + "), {db: 1}).version" + if got := eval(t, src); got != "3\n" { + t.Errorf("trailing non-channel hash got=%q", got) + } +} + +func TestNetSFTPStartYieldsBlock(t *testing.T) { + got := sftpEval(t, 3, concat(3), "") // start already ran with body="" and a session bound + _ = got + src := sftpChanClass + "require \"net/sftp\"\n" + + "Net::SFTP.start(\"h\", \"u\", channel: Chan.new(" + rubyBin(concat(3)) + ")) { |s| p s.version }" + if got := eval(t, src); got != "3\n" { + t.Errorf("start block got=%q", got) + } +} + +func TestNetSFTPNoChannel(t *testing.T) { + cls, _ := evalErr(t, `require "net/sftp"; Net::SFTP.start("h", "u")`) + if cls != "ArgumentError" { + t.Errorf("no channel: class=%q", cls) + } +} + +// --- open / close / read / write ------------------------------------------- + +func TestNetSFTPOpenCloseReadWrite(t *testing.T) { + handle := []byte("H1") + // open! (v3, OpenFlagsV1 path) returns the handle bytes. + reply := concat(3, sftpHandleFrame(0, handle)) + if got := sftpEval(t, 3, reply, `p sftp.open!("/f").bytesize`); got != "2\n" { + t.Errorf("open! got=%q", got) + } + // open! on a v6 server drives the OpenFlagsV5 path. + if got := sftpEval(t, 6, concat(6, sftpHandleFrame(0, handle)), `p sftp.open!("/f", "w").bytesize`); got != "2\n" { + t.Errorf("open! v6 got=%q", got) + } + // open! with a mode and permissions attribute Hash. + if got := sftpEval(t, 3, concat(3, sftpHandleFrame(0, handle)), `p sftp.open!("/f", "w", permissions: 0644).bytesize`); got != "2\n" { + t.Errorf("open! attrs got=%q", got) + } + // open! bad mode -> ArgumentError. + if cls, _ := sftpErr(t, 3, concat(3), `sftp.open!("/f", "z")`); cls != "ArgumentError" { + t.Errorf("open! bad mode class=%q", cls) + } + // open! failure -> StatusException. + cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "nope", 3)), `sftp.open!("/f")`) + if cls != "Net::SFTP::StatusException" { + t.Errorf("open! fail class=%q", cls) + } + // close! ok. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.close!("H")`); got != "nil\n" { + t.Errorf("close! got=%q", got) + } + // close! error. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "bad", 3)), `sftp.close!("H")`); cls != "Net::SFTP::StatusException" { + t.Errorf("close! err class=%q", cls) + } + // read! returns data. + if got := sftpEval(t, 3, concat(3, sftpDataFrame(0, []byte("abc"))), `p sftp.read!("H", 0, 10)`); got != "\"abc\"\n" { + t.Errorf("read! got=%q", got) + } + // read! at EOF returns nil. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_EOF, "", 3)), `p sftp.read!("H", 0, 10)`); got != "nil\n" { + t.Errorf("read! eof got=%q", got) + } + // read! error status raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "x", 3)), `sftp.read!("H", 0, 10)`); cls != "Net::SFTP::StatusException" { + t.Errorf("read! err class=%q", cls) + } + // write! ok + error. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.write!("H", 0, "data")`); got != "nil\n" { + t.Errorf("write! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_PERMISSION_DENIED, "", 3)), `sftp.write!("H", 0, "d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("write! err class=%q", cls) + } +} + +// --- stat family ----------------------------------------------------------- + +func TestNetSFTPStat(t *testing.T) { + a := &sftp.Attributes{Size: u64p(1234), Permissions: u32p(0o100644), UID: u32p(7), GID: u32p(8), Atime: u64p(111), Mtime: u64p(222)} + reply := concat(3, sftpAttrsFrame(0, a, 3)) + body := `st = sftp.stat!("/f") +p st.size +p st.permissions +p st.uid +p st.gid +p st.atime +p st.mtime +p st.file? +p st.directory? +p st.symlink? +p st.type` + want := "1234\n33188\n7\n8\n111\n222\ntrue\nfalse\nfalse\n1\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("stat! got=%q want=%q", got, want) + } + // lstat!. + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), `p sftp.lstat!("/f").size`); got != "1234\n" { + t.Errorf("lstat! got=%q", got) + } + // fstat!. + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), `p sftp.fstat!("H").size`); got != "1234\n" { + t.Errorf("fstat! got=%q", got) + } + // stat! failure raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.stat!("/x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("stat! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.lstat!("/x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("lstat! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `sftp.fstat!("H")`); cls != "Net::SFTP::StatusException" { + t.Errorf("fstat! err class=%q", cls) + } +} + +func TestNetSFTPAttributesNilFields(t *testing.T) { + // A directory with only the type bit set: optional numeric fields are nil. + a := &sftp.Attributes{Permissions: u32p(0o040755)} + body := `st = sftp.stat!("/d") +p st.size +p st.uid +p st.gid +p st.owner +p st.group +p st.directory?` + want := "nil\nnil\nnil\nnil\nnil\ntrue\n" + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), body); got != want { + t.Errorf("nil-fields got=%q want=%q", got, want) + } + // A v4 attrs with owner/group strings and a symlink type. + owner, group := "root", "wheel" + sa := &sftp.Attributes{Type: func() *uint8 { v := uint8(sftp.TSymlink); return &v }(), Owner: &owner, Group: &group, Permissions: u32p(0o120777)} + body = `st = sftp.stat!("/l") +p st.owner +p st.group +p st.symlink?` + if got := sftpEval(t, 6, concat(6, sftpAttrsFrame(0, sa, 6)), body); got != "\"root\"\n\"wheel\"\ntrue\n" { + t.Errorf("v6 owner/group got=%q", got) + } +} + +// --- setstat / mkdir attrs ------------------------------------------------- + +func TestNetSFTPSetstat(t *testing.T) { + // A full attribute Hash covers every sftpAttrsArg branch; one key is a String + // so the symbol->string fallback lookup runs too. + attrsHash := `{size: 10, permissions: 0644, uid: 1, gid: 2, owner: "a", group: "b", atime: 3, "mtime" => 4}` + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.setstat!("/f", `+attrsHash+`)`); got != "nil\n" { + t.Errorf("setstat! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.setstat!("/f", {})`); cls != "Net::SFTP::StatusException" { + t.Errorf("setstat! err class=%q", cls) + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.fsetstat!("H", {size: 1})`); got != "nil\n" { + t.Errorf("fsetstat! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.fsetstat!("H", {})`); cls != "Net::SFTP::StatusException" { + t.Errorf("fsetstat! err class=%q", cls) + } +} + +// --- directory ops --------------------------------------------------------- + +func TestNetSFTPDirEntryOps(t *testing.T) { + // mkdir!/rmdir!/remove! ok + error. + for _, m := range []string{"mkdir!", "rmdir!", "remove!"} { + call := m + arg := `"/d"` + if m == "mkdir!" { + arg = `"/d", {permissions: 0755}` + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.`+call+`(`+arg+`)`); got != "nil\n" { + t.Errorf("%s got=%q", m, got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.`+call+`(`+arg+`)`); cls != "Net::SFTP::StatusException" { + t.Errorf("%s err class=%q", m, cls) + } + } + // opendir! returns handle, error raises. + if got := sftpEval(t, 3, concat(3, sftpHandleFrame(0, []byte("D"))), `p sftp.opendir!("/d")`); got != "\"D\"\n" { + t.Errorf("opendir! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_PATH, "", 3)), `sftp.opendir!("/d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("opendir! err class=%q", cls) + } +} + +func TestNetSFTPReaddir(t *testing.T) { + fa := &sftp.Attributes{Permissions: u32p(0o100644), Size: u64p(5), Owner: strPtr("u"), Group: strPtr("g"), Atime: u64p(900), Mtime: u64p(1000)} + da := &sftp.Attributes{Permissions: u32p(0o040755)} + names := []sftp.Name{ + {Filename: "file.txt", Longname: "-rw-r--r-- file.txt", Attributes: fa}, + {Filename: "sub", Longname: "drwxr-xr-x sub", Attributes: da}, + } + // readdir! returns an array of Name entries with accessors. + body := `es = sftp.readdir!("D") +p es.length +p es[0].name +p es[0].filename +p es[0].longname +p es[0].file? +p es[1].directory? +p es[0].attributes.size` + want := "2\n\"file.txt\"\n\"file.txt\"\n\"-rw-r--r-- file.txt\"\ntrue\ntrue\n5\n" + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, names)), body); got != want { + t.Errorf("readdir! got=%q want=%q", got, want) + } + // readdir! at EOF returns nil. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_EOF, "", 3)), `p sftp.readdir!("D")`); got != "nil\n" { + t.Errorf("readdir! eof got=%q", got) + } + // readdir! error raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.readdir!("D")`); cls != "Net::SFTP::StatusException" { + t.Errorf("readdir! err class=%q", cls) + } + // v6 longname is synthesised from attributes (no wire longname). + v6names := []sftp.Name{{Filename: "f", Attributes: fa}} + got := sftpEval(t, 6, concat(6, sftpNameFrame(0, 6, v6names)), `p sftp.readdir!("D")[0].longname.include?("f")`) + if got != "true\n" { + t.Errorf("v6 longname got=%q", got) + } +} + +// --- realpath / links ------------------------------------------------------ + +func TestNetSFTPRealpathAndLinks(t *testing.T) { + da := &sftp.Attributes{Permissions: u32p(0o040755)} + name := []sftp.Name{{Filename: "/abs/path", Attributes: da}} + // realpath! returns the canonical name. + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, name)), `p sftp.realpath!("x")`); got != "\"/abs/path\"\n" { + t.Errorf("realpath! got=%q", got) + } + // realpath! error raises. + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_PATH, "", 3)), `sftp.realpath!("x")`); cls != "Net::SFTP::StatusException" { + t.Errorf("realpath! err class=%q", cls) + } + // realpath! with an empty NAME response raises the codec exception. + if cls, _ := sftpErr(t, 3, concat(3, sftpNameFrame(0, 3, nil)), `sftp.realpath!("x")`); cls != "Net::SFTP::Exception" { + t.Errorf("realpath! empty class=%q", cls) + } + + // rename! ok (v3) + error (server failure). + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.rename!("a", "b")`); got != "nil\n" { + t.Errorf("rename! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.rename!("a", "b")`); cls != "Net::SFTP::StatusException" { + t.Errorf("rename! err class=%q", cls) + } + // rename! unavailable in v1 -> the library gate raises Net::SFTP::Exception. + if cls, _ := sftpErr(t, 1, concat(1), `sftp.rename!("a", "b")`); cls != "Net::SFTP::Exception" { + t.Errorf("rename! v1 class=%q", cls) + } + + // readlink! ok returns target; error raises; v2 gate raises. + tn := []sftp.Name{{Filename: "target", Attributes: da}} + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, tn)), `p sftp.readlink!("l")`); got != "\"target\"\n" { + t.Errorf("readlink! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.readlink!("l")`); cls != "Net::SFTP::StatusException" { + t.Errorf("readlink! err class=%q", cls) + } + if cls, _ := sftpErr(t, 2, concat(2), `sftp.readlink!("l")`); cls != "Net::SFTP::Exception" { + t.Errorf("readlink! v2 class=%q", cls) + } + + // symlink! ok (v3) + error + v2 gate. + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_OK, "", 3)), `p sftp.symlink!("l", "t")`); got != "nil\n" { + t.Errorf("symlink! got=%q", got) + } + if cls, _ := sftpErr(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "", 3)), `sftp.symlink!("l", "t")`); cls != "Net::SFTP::StatusException" { + t.Errorf("symlink! err class=%q", cls) + } + if cls, _ := sftpErr(t, 2, concat(2), `sftp.symlink!("l", "t")`); cls != "Net::SFTP::Exception" { + t.Errorf("symlink! v2 class=%q", cls) + } + + // link! ok (v6) + error + v3 gate; block! / unblock! same. + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.link!("n", "e", true)`); got != "nil\n" { + t.Errorf("link! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_FAILURE, "", 6)), `sftp.link!("n", "e", false)`); cls != "Net::SFTP::StatusException" { + t.Errorf("link! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.link!("n", "e")`); cls != "Net::SFTP::Exception" { + t.Errorf("link! v3 class=%q", cls) + } + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.block!("H", 0, 10, 1)`); got != "nil\n" { + t.Errorf("block! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_LOCK_CONFLICT, "", 6)), `sftp.block!("H", 0, 10, 1)`); cls != "Net::SFTP::StatusException" { + t.Errorf("block! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.block!("H", 0, 10, 1)`); cls != "Net::SFTP::Exception" { + t.Errorf("block! v3 class=%q", cls) + } + if got := sftpEval(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_OK, "", 6)), `p sftp.unblock!("H", 0, 10)`); got != "nil\n" { + t.Errorf("unblock! got=%q", got) + } + if cls, _ := sftpErr(t, 6, concat(6, sftpStatusFrame(0, sftp.FX_FAILURE, "", 6)), `sftp.unblock!("H", 0, 10)`); cls != "Net::SFTP::StatusException" { + t.Errorf("unblock! err class=%q", cls) + } + if cls, _ := sftpErr(t, 3, concat(3), `sftp.unblock!("H", 0, 10)`); cls != "Net::SFTP::Exception" { + t.Errorf("unblock! v3 class=%q", cls) + } +} + +// --- upload / download ----------------------------------------------------- + +func TestNetSFTPTransfer(t *testing.T) { + // download!: open -> read (data) -> read (EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpDataFrame(1, []byte("hello world")), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `p sftp.download!("/remote")`); got != "\"hello world\"\n" { + t.Errorf("download! got=%q", got) + } + // download! read error raises. + badReply := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpStatusFrame(1, sftp.FX_PERMISSION_DENIED, "", 3), + ) + if cls, _ := sftpErr(t, 3, badReply, `sftp.download!("/r")`); cls != "Net::SFTP::StatusException" { + t.Errorf("download! err class=%q", cls) + } + // upload! from a String: open -> write -> close. + up := concat(3, + sftpHandleFrame(0, []byte("H")), + sftpStatusFrame(1, sftp.FX_OK, "", 3), + sftpStatusFrame(2, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, up, `p sftp.upload!("payload", "/remote")`); got != "nil\n" { + t.Errorf("upload! got=%q", got) + } + // upload! from an IO-like source (responds to #read). + src := sftpChanClass + "require \"net/sftp\"\n" + + "require \"stringio\"\n" + + "ch = Chan.new(" + rubyBin(up) + ")\n" + + "sftp = Net::SFTP.start(channel: ch)\n" + + `p sftp.upload!(StringIO.new("io-payload"), "/remote")` + if got := eval(t, src); got != "nil\n" { + t.Errorf("upload! io got=%q", got) + } +} + +// --- dir / file operation helpers ------------------------------------------ + +func TestNetSFTPDirHelper(t *testing.T) { + fa := &sftp.Attributes{Permissions: u32p(0o100644), Size: u64p(3), Atime: u64p(1), Mtime: u64p(1)} + names := []sftp.Name{{Filename: "a", Longname: "a", Attributes: fa}, {Filename: "b", Longname: "b", Attributes: fa}} + // dir.entries: opendir -> readdir(names) -> readdir(EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("D")), + sftpNameFrame(1, 3, names), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `p sftp.dir.entries("/d").map(&:name)`); got != "[\"a\", \"b\"]\n" { + t.Errorf("dir.entries got=%q", got) + } + // dir.foreach yields each entry. + reply = concat(3, + sftpHandleFrame(0, []byte("D")), + sftpNameFrame(1, 3, names), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `sftp.dir.foreach("/d") { |e| puts e.name }`); got != "a\nb\n" { + t.Errorf("dir.foreach got=%q", got) + } + // dir.foreach without a block raises. + reply = concat(3, sftpHandleFrame(0, []byte("D")), sftpStatusFrame(1, sftp.FX_EOF, "", 3), sftpStatusFrame(2, sftp.FX_OK, "", 3)) + if cls, _ := sftpErr(t, 3, reply, `sftp.dir.foreach("/d")`); cls != "LocalJumpError" { + t.Errorf("dir.foreach no-block class=%q", cls) + } +} + +func TestNetSFTPFileHelper(t *testing.T) { + // file.open block form: open -> (read data, read EOF) -> close. + reply := concat(3, + sftpHandleFrame(0, []byte("F")), + sftpDataFrame(1, []byte("abc")), + sftpStatusFrame(2, sftp.FX_EOF, "", 3), + sftpStatusFrame(3, sftp.FX_OK, "", 3), + ) + if got := sftpEval(t, 3, reply, `sftp.file.open("/f") { |f| p f.read }`); got != "\"abc\"\n" { + t.Errorf("file.open block read got=%q", got) + } + // file.open non-block form + explicit read(len)/pos/close (with a redundant + // second close to hit the already-closed guard). + reply = concat(3, + sftpHandleFrame(0, []byte("F")), + sftpDataFrame(1, []byte("xy")), + sftpStatusFrame(2, sftp.FX_OK, "", 3), + ) + body := `f = sftp.file.open("/f", "r") +p f.read(2) +p f.pos +p f.close +p f.close` + if got := sftpEval(t, 3, reply, body); got != "\"xy\"\n2\nnil\nnil\n" { + t.Errorf("file.open explicit got=%q", got) + } + // file read(len) at EOF returns nil. + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_EOF, "", 3)) + if got := sftpEval(t, 3, reply, `p sftp.file.open("/f").read(4)`); got != "nil\n" { + t.Errorf("file read eof got=%q", got) + } + // file.write advances pos and returns the byte count. + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + body = `f = sftp.file.open("/f", "w") +p f.write("hello") +p f.pos` + if got := sftpEval(t, 3, reply, body); got != "5\n5\n" { + t.Errorf("file.write got=%q", got) + } + // file.open with attributes Hash (mode + permissions). + reply = concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + if got := sftpEval(t, 3, reply, `p sftp.file.open("/f", "w", permissions: 0600).close`); got != "nil\n" { + t.Errorf("file.open attrs got=%q", got) + } + // file.directory? true and false. + da := &sftp.Attributes{Permissions: u32p(0o040755)} + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, da, 3)), `p sftp.file.directory?("/d")`); got != "true\n" { + t.Errorf("file.directory? true got=%q", got) + } + if got := sftpEval(t, 3, concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)), `p sftp.file.directory?("/x")`); got != "false\n" { + t.Errorf("file.directory? false got=%q", got) + } +} + +// --- StatusException surface ----------------------------------------------- + +func TestNetSFTPStatusException(t *testing.T) { + // The raised StatusException exposes code / description / text / message. + reply := concat(3, sftpStatusFrame(0, sftp.FX_NO_SUCH_FILE, "", 3)) + body := `begin + sftp.stat!("/missing") +rescue Net::SFTP::StatusException => e + p e.code + p e.description + p e.text + p e.message.include?("stat") + p e.to_s.include?("no such file") +end` + want := "2\n\"no such file\"\n\"stat\"\ntrue\ntrue\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("StatusException got=%q want=%q", got, want) + } + // A server-supplied message overrides the canonical description. + reply = concat(3, sftpStatusFrame(0, sftp.FX_FAILURE, "custom boom", 3)) + body = `begin + sftp.remove!("/x") +rescue Net::SFTP::StatusException => e + p e.description +end` + if got := sftpEval(t, 3, reply, body); got != "\"custom boom\"\n" { + t.Errorf("StatusException custom msg got=%q", got) + } +} + +// --- driver / protocol error paths ----------------------------------------- + +func TestNetSFTPProtocolErrors(t *testing.T) { + // Channel closes before a complete packet -> Net::SFTP::Exception. + if cls, _ := evalErr(t, sftpChanClass+"require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(\"\".b))"); cls != "Net::SFTP::Exception" { + t.Errorf("empty channel class=%q", cls) + } + // First packet is not FXP_VERSION. + notVersion := sftpStatusFrame(0, sftp.FX_OK, "", 3) + src := sftpChanClass + "require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(" + rubyBin(notVersion) + "))" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("non-version class=%q", cls) + } + // A truncated FXP_VERSION payload (no version word) -> ParseVersion error. + badVer := sftp.FramePacket(sftp.FXP_VERSION, []byte{0x00}) + src = sftpChanClass + "require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new(" + rubyBin(badVer) + "))" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("bad-version class=%q", cls) + } + // Server version 0 -> NegotiateVersion error. + if cls, _ := evalErr(t, sftpChanClass+"require \"net/sftp\"\nNet::SFTP.start(channel: Chan.new("+rubyBin(sftpVersionFrame(0))+"))"); cls != "Net::SFTP::Exception" { + t.Errorf("version-0 class=%q", cls) + } + // Unknown response packet type -> ParseResponse error. + unknown := sftp.FramePacket(sftp.FXP_EXTENDED_REPLY, []byte{0, 0, 0, 0}) + if cls, _ := sftpErr(t, 3, concat(3, unknown), `sftp.stat!("/f")`); cls != "Net::SFTP::Exception" { + t.Errorf("unknown-response class=%q", cls) + } + // Response id mismatch (a HANDLE echoing id 9 for request id 0). + if cls, _ := sftpErr(t, 3, concat(3, sftpHandleFrame(9, []byte("H"))), `sftp.open!("/f")`); cls != "Net::SFTP::Exception" { + t.Errorf("id-mismatch class=%q", cls) + } + // Packet reassembly across multiple chunked reads (@chunk = 1). + reply := concat(3, sftpHandleFrame(0, []byte("H"))) + src = sftpChanClass + "require \"net/sftp\"\n" + + "ch = Chan.new(" + rubyBin(reply) + ", 1)\n" + + "sftp = Net::SFTP.start(channel: ch)\n" + + `p sftp.open!("/f")` + if got := eval(t, src); got != "\"H\"\n" { + t.Errorf("chunked read got=%q", got) + } +} + +// --- argument type errors -------------------------------------------------- + +func TestNetSFTPArgTypeErrors(t *testing.T) { + cases := []struct{ body, class string }{ + {`sftp.close!(123)`, "TypeError"}, // handle not a String + {`sftp.stat!(123)`, "TypeError"}, // path not a String + {`sftp.read!("H", "x", 1)`, "TypeError"}, // offset not an Integer + {`sftp.read!("H", 0, "x")`, "TypeError"}, // length not an Integer + } + for _, c := range cases { + if cls, _ := sftpErr(t, 3, concat(3), c.body); cls != c.class { + t.Errorf("body=%q got class=%q want=%q", c.body, cls, c.class) + } + } +} + +// --- wrapper reprs + remaining branches ------------------------------------ + +func TestNetSFTPWrapperReprs(t *testing.T) { + // Session / dir / file-factory to_s / inspect / truthiness. + reply := concat(3, sftpHandleFrame(0, []byte("F")), sftpStatusFrame(1, sftp.FX_OK, "", 3)) + body := `p sftp.to_s +p sftp.inspect +p(sftp ? "y" : "n") +d = sftp.dir +p d.to_s +p d.inspect +p(d ? 1 : 0) +ff = sftp.file +p ff.to_s +p ff.inspect +p(ff ? 1 : 0) +f = ff.open("/f", "w") +p f.to_s +p f.inspect +p(f ? 1 : 0)` + want := "\"#\"\n\"#\"\n\"y\"\n" + + "\"#\"\n\"#\"\n1\n" + + "\"#\"\n\"#\"\n1\n" + + "\"#\"\n\"#\"\n1\n" + if got := sftpEval(t, 3, reply, body); got != want { + t.Errorf("wrapper reprs got=%q want=%q", got, want) + } + + // Attributes wrapper to_s / inspect / truthiness. + a := &sftp.Attributes{Permissions: u32p(0o100644)} + abody := `st = sftp.stat!("/f") +p st.to_s +p st.inspect +p(st ? 1 : 0)` + awant := "\"#\"\n\"#\"\n1\n" + if got := sftpEval(t, 3, concat(3, sftpAttrsFrame(0, a, 3)), abody); got != awant { + t.Errorf("attrs reprs got=%q", got) + } + + // Name wrapper to_s / inspect / truthiness / symlink?. + sa := &sftp.Attributes{Permissions: u32p(0o120777)} + names := []sftp.Name{{Filename: "l", Longname: "l", Attributes: sa}} + nbody := `n = sftp.readdir!("D")[0] +p n.to_s +p n.inspect +p(n ? 1 : 0) +p n.symlink?` + nwant := "\"#\"\n\"#\"\n1\ntrue\n" + if got := sftpEval(t, 3, concat(3, sftpNameFrame(0, 3, names)), nbody); got != nwant { + t.Errorf("name reprs got=%q", got) + } +} + +func TestNetSFTPDirEntriesError(t *testing.T) { + // dir.entries whose readdir yields an error status raises (readdirAll branch). + reply := concat(3, + sftpHandleFrame(0, []byte("D")), + sftpStatusFrame(1, sftp.FX_FAILURE, "boom", 3), + ) + if cls, _ := sftpErr(t, 3, reply, `sftp.dir.entries("/d")`); cls != "Net::SFTP::StatusException" { + t.Errorf("dir.entries error class=%q", cls) + } +} + +func TestNetSFTPChannelReadNil(t *testing.T) { + // A channel whose #read returns nil (not a String) is treated as a closed + // stream (sftpReadBytes nil branch -> Net::SFTP::Exception at connect). + src := "class NilChan\n def write(s); s.bytesize; end\n def read(n = nil); nil; end\nend\n" + + "require \"net/sftp\"\nNet::SFTP.start(channel: NilChan.new)" + if cls, _ := evalErr(t, src); cls != "Net::SFTP::Exception" { + t.Errorf("nil-read class=%q", cls) + } +} + +func strPtr(s string) *string { return &s } diff --git a/internal/vm/object_model.go b/internal/vm/object_model.go index 8b434a8a..875b1876 100644 --- a/internal/vm/object_model.go +++ b/internal/vm/object_model.go @@ -974,6 +974,18 @@ func (vm *VM) classOf(v object.Value) *RClass { return vm.consts["Typhoeus::Response"].(*RClass) case *TyphoeusHydra: return vm.consts["Typhoeus::Hydra"].(*RClass) + case *sftpSession: + return x.cls + case *sftpDir: + return x.cls + case *sftpFileFactory: + return x.cls + case *sftpFile: + return x.cls + case *sftpAttrs: + return x.cls + case *sftpName: + return x.cls case *RedisObj: return x.cls case *RedisBatch: diff --git a/internal/vm/require.go b/internal/vm/require.go index 281b7a1d..0c3887e6 100644 --- a/internal/vm/require.go +++ b/internal/vm/require.go @@ -33,6 +33,7 @@ var providedFeatures = map[string]bool{ "concurrent": true, "syslog": true, "cgi": true, "monitor": true, "observer": true, "socket": true, + "net/sftp": true, "net/http": true, "net/https": true, "resolv": true, "singleton": true, "optparse": true, "ripper": true, "erb": true, "find": true, "tempfile": true, "open3": true, diff --git a/internal/vm/vm.go b/internal/vm/vm.go index 1968ce26..db994ed7 100644 --- a/internal/vm/vm.go +++ b/internal/vm/vm.go @@ -285,6 +285,7 @@ type VM struct { cActionMailerAttachments *RClass // the internal `attachments` proxy class (Mail::AttachmentsList analogue) amDefs map[*RClass]*amDef // ActionMailer: per-mailer-class default/delivery/hook declarations, merged down the chain at delivery time amDeliveries *object.Array // ActionMailer: the shared ActionMailer::Base.deliveries Array the :test delivery method appends to + sftpClasses *sftpClassSet // Net::SFTP: the wrapper classes stamped on returned Attributes / Name / File values (require "net/sftp") amMailerOf map[*RObject]*actionmailer.Mailer // ActionMailer: mailer instance -> library *Mailer of its running action (the mail/attachments/headers seam) amRenderer object.Value // ActionMailer: the Ruby renderer the RenderBody seam sends #render to (Action View wiring / test stub) amEnqueuer object.Value // ActionMailer: the Ruby enqueuer the deliver_later seam sends #enqueue to (Active Job wiring / test stub)