netftp

package module
v0.0.0-...-f1abf96 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Jul 17, 2026 License: BSD-3-Clause Imports: 4 Imported by: 0

README

go-ruby-net-ftp/net-ftp

net-ftp — go-ruby-net-ftp

Docs License Go Coverage

A pure-Go (no cgo) reimplementation of the deterministic, interpreter-independent core of Ruby's Net::FTP — MRI's net-ftp. It builds the FTP command bytes a client sends on the control connection, parses the 3-digit (optionally multiline) replies a server returns, extracts the data-connection address from PASV / EPSV responses, and parses MLSD / MLST entries and quoted pathnames — everything Net::FTP does that is pure computation over bytes, with no I/O and no Ruby runtime.

It is the FTP protocol codec for go-embedded-ruby, but is a standalone, reusable module — a sibling of go-ruby-net-smtp and go-ruby-regexp.

What it is — and isn't. Constructing command lines, classifying reply codes, extracting a PASV/EPSV host:port, and parsing the MLSx fact grammar are fully deterministic and need no interpreter, so they live here as pure Go. The control and data sockets — connecting, reading, writing, TLS — are the host's job. They are seams: this library never opens a socket. The host feeds raw reply lines in (via a LineReader) and writes the command bytes this library produces out.

Features

Faithful port of Net::FTP's pure-compute core, validated against the ruby binary on every supported platform:

  • Command builders for USER/PASS/ACCT/CWD/CDUP/PWD/TYPE/PASV/ EPSV/PORT/EPRT/LIST/NLST/MLSD/MLST/RETR/STOR/DELE/RNFR/ RNTO/MKD/RMD/SIZE/MDTM/SYST/STAT/FEAT/OPTS/SITE/HELP/ NOOP/ABOR/QUIT — byte-for-byte with MRI's construction, plus PutLine (CRLF append + CR/LF rejection) and Sanitize (PASS masking).
  • Reply parsingGetMultiline assembles single- and multiline (220-… / 220 …) replies; ClassifyReply maps the leading digit to success / Temp / Perm / Proto, exactly as getresp does; VoidResp enforces the 2yz rule with MRI's ordering; ReplyBody is get_body.
  • PASV / EPSV address extractionParse227 (227 (h1,h2,h3,h4,p1,p2)host:port, honouring use_pasv_ip), Parse229 (229 (|||port|)), and the IPv6 PasvIPv6Host helper, with Parse257's quoted-pathname (""-collapsing) decode.
  • MLSD / MLST parsingParseMLSxEntry reproduces parse_mlsx_entry and the full FACT_PARSERS table (decimal / octal / time / case-folded / verbatim), returning an MLSxEntry with the perm-bit predicates (IsFile/IsDirectory/Readable/Writable/…).
  • The Net::FTPError familyFTPReplyError / FTPTempError / FTPPermError / FTPProtoError / FTPConnectionError, keyed by reply code and carrying MRI's message text, each with the Ruby class name the host re-raises.
  • CGO=0, no dependencies beyond the standard library, and pure-Go on all six 64-bit Go targets (amd64, arm64, riscv64, loong64, ppc64le, s390x).

The socket seams

The host supplies two things; everything else is computed here:

  • Reading — a LineReader (func() (string, error)) that yields successive raw lines from the control connection. GetMultiline / GetResp / VoidResp call it; an error it returns stands in for MRI's EOFError / read failure.
  • Writing — the host writes the bytes a command builder returns (terminated via PutLine). The data connection (active PORT/EPRT or passive PASV/EPSV) is dialed by the host using the host/port this library extracts from the reply.

Tests & coverage

GOWORK=off go test -race -cover ./...

The suite is 100% statement coverage. The deterministic, Ruby-free tests cover every branch on their own (so the Windows and qemu cross-arch CI lanes hold the gate); where the ruby binary is present, the oracle_test.go suite additionally pins command bytes, reply parse, PASV/EPSV extraction, and MLSx parsing to live Net::FTP output.

License

BSD-3-Clause — see LICENSE. Copyright (c) 2026, the go-ruby-net-ftp/net-ftp authors.

Documentation

Overview

Package netftp is a pure-Go (CGO=0) reimplementation of the deterministic, interpreter-independent core of Ruby's Net::FTP (MRI's net-ftp 0.3.9).

It is the protocol codec: it builds the FTP command bytes a client sends on the control connection, parses the 3-digit (optionally multiline) replies a server returns, extracts the data-connection address from PASV / EPSV responses, and parses MLSD / MLST entries and quoted pathnames — everything Net::FTP does that is pure computation over bytes, with no I/O.

The control and data sockets — connecting, reading, writing, TLS — are the host's job. They are SEAMS: this package never opens a socket. The host (go-embedded-ruby's rbgo) feeds reply text in and writes the command bytes this package produces out. Every observable byte — command line, reply classification, PASV/EPSV host:port, MLSx facts — matches MRI's net-ftp byte-for-byte, validated against the `ruby -rnet/ftp` binary.

Index

Constants

View Source
const AborCommand = "ABOR"

AborCommand is the ABOR command (abort); MRI sends it out-of-band.

View Source
const AnonymousPassword = "anonymous@"

AnonymousPassword returns the password Net::FTP substitutes for an anonymous login when none is given: "anonymous@" (login: user == "anonymous" and passwd == nil → passwd = "anonymous@").

View Source
const CRLF = "\r\n"

CRLF is the line terminator Net::FTP appends to every control-connection command (Net::FTP::CRLF).

View Source
const CdupCommand = "CDUP"

CdupCommand is the CDUP command sent by chdir("..") before falling back to CWD on a 500 reply.

View Source
const EpsvCommand = "EPSV"

EpsvCommand is the EPSV command (passive transfers, extended/IPv6).

View Source
const FeatCommand = "FEAT"

FeatCommand is the FEAT command (features).

View Source
const NoopCommand = "NOOP"

NoopCommand is the NOOP command (noop).

View Source
const PasvCommand = "PASV"

PasvCommand is the PASV command (set_socket / passive transfers, IPv4).

View Source
const PwdCommand = "PWD"

PwdCommand is the PWD command (pwd / getdir).

View Source
const QuitCommand = "QUIT"

QuitCommand is the QUIT command (quit).

View Source
const SystCommand = "SYST"

SystCommand is the SYST command (system / .system).

Variables

View Source
var ErrLineHasCRLF = errors.New("A line must not contain CR or LF")

ErrLineHasCRLF is returned by PutLine when a command line contains a CR or LF, mirroring MRI's putline: `raise ArgumentError, "A line must not contain CR or LF"`.

Functions

func AbortAccepted

func AbortAccepted(resp string) bool

AbortAccepted reports whether an ABOR reply is one MRI accepts (426, 226, or 225); otherwise MRI raises FTPProtoError. The reply's first three characters are tested.

func AcctCommand

func AcctCommand(acct string) string

AcctCommand builds the ACCT command (MRI: 'ACCT ' + acct).

func AnonymousLogin

func AnonymousLogin(user string, passwdGiven bool) (string, bool)

AnonymousLogin reports the password Net::FTP substitutes when logging in as "anonymous" with no password: ("anonymous@", true). For any other user, or when a password is already supplied, it returns ("", false).

func CheckDelete

func CheckDelete(resp string) error

CheckDelete classifies the reply to a DELE command, mirroring MRI delete:

  • "250…" → success (nil).
  • "5…" → FTPPermError.
  • anything else → FTPReplyError.

func CheckRenameFrom

func CheckRenameFrom(resp string) error

CheckRenameFrom classifies the reply to an RNFR command, mirroring MRI rename: a reply not beginning with '3' is an FTPReplyError; otherwise RNTO follows.

func ClassifyReply

func ClassifyReply(resp string) (string, error)

ClassifyReply applies MRI's getresp classification to an assembled reply:

  • 1yz / 2yz / 3yz → returns the reply with a nil error (success).
  • 4yz → FTPTempError.
  • 5yz → FTPPermError.
  • anything else → FTPProtoError.

The first byte of the code drives the decision, as in MRI's case/when on /\A[123]/, /\A4/, /\A5/.

func CwdCommand

func CwdCommand(dirname string) string

CwdCommand builds the CWD command (MRI chdir: "CWD #{dirname}").

func DeleCommand

func DeleCommand(filename string) string

DeleCommand builds the DELE command (MRI delete: "DELE #{filename}").

func GetMultiline

func GetMultiline(read LineReader) (string, error)

GetMultiline assembles one complete reply from the control connection, mirroring MRI's getmultiline. It reads the first line; if that line is a multiline opener ("NNN-..."), it keeps reading until a line begins with "NNN " (the code followed by a space). The lines are joined with "\n" and a trailing "\n" is appended, exactly as MRI returns them.

read is the host-supplied socket seam; any error it returns (EOF, I/O) is propagated unchanged.

func GetResp

func GetResp(read LineReader) (string, error)

GetResp reads one reply (GetMultiline) and classifies it (ClassifyReply), mirroring MRI's getresp. On success it returns the assembled reply; on a 4yz/5yz/other reply it returns the matching FTPError; a read error from the seam is propagated unchanged.

func HelpCommand

func HelpCommand(arg string) string

HelpCommand builds the HELP command (MRI help: "HELP", optionally " " + arg).

func ListCommand

func ListCommand(args ...string) string

ListCommand builds the LIST command, appending each argument separated by a space, mirroring MRI's list: `cmd = "LIST"; args.each { cmd = "#{cmd} #{arg}" }`.

func MdtmCommand

func MdtmCommand(filename string) string

MdtmCommand builds the MDTM command (MRI mdtm: "MDTM #{filename}").

func MdtmResult

func MdtmResult(resp string) (string, bool)

MdtmResult extracts the raw "YYYYMMDDhhmmss" timestamp from an MDTM reply, mirroring MRI mdtm: when the reply begins with "213" the body after "NNN " is returned (ok true); otherwise MRI returns nil — here ok is false.

func MkdCommand

func MkdCommand(dirname string) string

MkdCommand builds the MKD command (MRI mkdir: "MKD #{dirname}").

func MlsdCommand

func MlsdCommand(pathname string) string

MlsdCommand builds the MLSD command (MRI mlsd: "MLSD", optionally " #{pathname}").

func MlstCommand

func MlstCommand(pathname string) string

MlstCommand builds the MLST command (MRI mlst: "MLST", optionally " #{pathname}").

func NlstCommand

func NlstCommand(dir string) string

NlstCommand builds the NLST command (MRI nlst: "NLST", optionally " #{dir}").

func OptionCommand

func OptionCommand(name, params string) string

OptionCommand builds the OPTS command (MRI option: "OPTS #{name}", optionally " #{params}").

func Parse227

func Parse227(resp string, usePasvIP bool, remoteAddr string) (host string, port int, err error)

Parse227 parses a 227 (Entering Passive Mode) reply, mirroring MRI parse227.

  • If resp does not start with "227" → FTPReplyError.
  • If the "(h1,h2,h3,h4,p1,p2)" pattern is absent → FTPProtoError.
  • Otherwise the port is (p1<<8)+p2.

The host is the seam: MRI returns the encoded h1.h2.h3.h4 when @use_pasv_ip is true, otherwise the control socket's remote address. usePasvIP selects between the two; remoteAddr supplies the socket's address for the false branch (the host passes what the data connection should dial).

func Parse229

func Parse229(resp string, remoteAddr string) (host string, port int, err error)

Parse229 parses a 229 (Extended Passive Mode) reply, mirroring MRI parse229.

  • If resp does not start with "229" → FTPReplyError.
  • If the "(|||port|)" pattern (delimiter repeated three times, then the port, then the delimiter once) is absent → FTPProtoError.

EPSV does not encode a host, so MRI returns the control socket's remote address; remoteAddr is that seam value, returned unchanged as host.

func Parse257

func Parse257(resp string) (string, error)

Parse257 extracts the quoted pathname from a 257 reply, mirroring MRI parse257.

  • If resp does not start with "257" → FTPReplyError.
  • Otherwise it returns the contents of the first "..." with doubled quotes ("") collapsed to a single quote. When no quoted string is present MRI's `.to_s` yields "" — this returns "" too.

func PassCommand

func PassCommand(passwd string) string

PassCommand builds the PASS command (MRI: 'PASS ' + passwd).

func PasvIPv6Host

func PasvIPv6Host(s string) string

PasvIPv6Host turns the comma-separated host group of an IPv6 PASV reply into a colon-separated hextet address, mirroring parse_pasv_ipv6_host: `s.split(",").map { "%02x" % i.to_i }.each_slice(2).map(&:join).join(":")`.

func PutLine

func PutLine(line string) (string, error)

PutLine validates a command line and appends CRLF, returning the bytes MRI's putline writes to the control socket. The actual write is the host's seam.

func ReplyBody

func ReplyBody(resp string) (string, bool)

ReplyBody returns the text after the 3-character code and one space, mirroring MRI's get_body: `resp.slice(/\A[0-9a-zA-Z]{3} (.*)$/, 1)`. The match is anchored at the start and stops at the first newline ($). It returns "", false when the reply does not start with "NNN " (a valid code plus a space).

func ReplyCode

func ReplyCode(resp string) string

ReplyCode returns the three-character reply code at the start of an assembled reply (MRI's `@last_response[0, 3]`). For a reply shorter than three characters it returns the whole reply, matching Ruby's slice semantics.

func RetrCommand

func RetrCommand(filename string) string

RetrCommand builds the RETR command used by the binary/text get helpers.

func RmdCommand

func RmdCommand(dirname string) string

RmdCommand builds the RMD command (MRI rmdir: "RMD #{dirname}").

func RnfrCommand

func RnfrCommand(fromname string) string

RnfrCommand builds the RNFR command (MRI rename: "RNFR #{fromname}").

func RntoCommand

func RntoCommand(toname string) string

RntoCommand builds the RNTO command (MRI rename: "RNTO #{toname}").

func Sanitize

func Sanitize(s string) string

Sanitize hides the password in a PASS command for debug output, mirroring MRI's sanitize: a line matching /^PASS /i keeps its first five characters and has the rest replaced with '*'. Any other line is returned unchanged.

func SendEPort

func SendEPort(host string, port int) string

SendEPort builds the EPRT command for an active-mode IPv6 data connection, mirroring MRI sendport: sprintf("EPRT |2|%s|%d|", host, port).

func SendPort

func SendPort(host string, port int) string

SendPort builds the PORT command for an active-mode IPv4 data connection, mirroring MRI sendport: "PORT " + (host.split(".") + port.divmod(256)).join(","). host is dotted-quad; the port's high and low bytes are appended.

func SiteCommand

func SiteCommand(arg string) string

SiteCommand builds the SITE command (MRI site: "SITE " + arg).

func SizeCommand

func SizeCommand(filename string) string

SizeCommand builds the SIZE command (MRI size: "SIZE #{filename}").

func SizeResult

func SizeResult(resp string) (int, error)

SizeResult extracts the file size from a SIZE reply, mirroring MRI size: a reply not beginning with "213" is an FTPReplyError; otherwise the body after "NNN " is read as an integer (String#to_i).

func StatCommand

func StatCommand(pathname string) string

StatCommand builds the STAT command (MRI status: "STAT", optionally " #{pathname}").

func StorCommand

func StorCommand(filename string) string

StorCommand builds the STOR command used by the put helpers.

func SystResult

func SystResult(resp string) (string, error)

SystResult extracts the system string from a SYST reply, mirroring MRI system: a reply not beginning with "215" is an FTPReplyError; otherwise the body after "NNN " is returned.

func TypeCommand

func TypeCommand(binary bool) string

TypeCommand builds the TYPE command for the current transfer mode, mirroring send_type_command: "TYPE I" when binary, "TYPE A" otherwise.

func UserCommand

func UserCommand(user string) string

UserCommand builds the USER command (MRI: 'USER ' + user).

func VoidResp

func VoidResp(read LineReader) error

VoidResp reads a reply and requires it to begin with '2', mirroring MRI's voidresp (`raise FTPReplyError, resp if !resp.start_with?("2")`). Note MRI calls getresp first, so a 4yz/5yz reply surfaces as Temp/Perm before the "starts with 2" check; this preserves that ordering.

Types

type FTPError

type FTPError struct {
	// Kind names the concrete Net::FTP subclass this error represents.
	Kind FTPErrorKind
	// Message is the reply (or other) text MRI passes to `raise`.
	Message string
}

FTPError is the base of the Net::FTP error family (Net::FTPError).

func (*FTPError) ClassName

func (e *FTPError) ClassName() string

ClassName returns the Ruby exception class name (e.g. "Net::FTPPermError") the host should raise for this error.

func (*FTPError) Error

func (e *FTPError) Error() string

Error implements the error interface, returning the reply text MRI uses as the exception message.

type FTPErrorKind

type FTPErrorKind int

FTPErrorKind enumerates the concrete Net::FTP error subclasses.

const (
	// KindReply is Net::FTPReplyError — an unexpected reply code.
	KindReply FTPErrorKind = iota
	// KindTemp is Net::FTPTempError — a 4yz transient negative reply.
	KindTemp
	// KindPerm is Net::FTPPermError — a 5yz permanent negative reply.
	KindPerm
	// KindProto is Net::FTPProtoError — a reply that is not 1/2/3/4/5yz, or
	// otherwise malformed.
	KindProto
	// KindConnection is Net::FTPConnectionError — a control-connection failure.
	KindConnection
)

type FactKind

type FactKind int

FactKind tags the shape of a FactValue.

const (
	// FactString is a textual fact (Net::FTP's CASE_DEPENDENT/INDEPENDENT
	// parsers, and the default for unknown facts).
	FactString FactKind = iota
	// FactInt is an integer fact (DECIMAL_PARSER / OCTAL_PARSER).
	FactInt
	// FactTime is a time fact (TIME_PARSER), exposed as MLSxTime.
	FactTime
)

type FactValue

type FactValue struct {
	Kind FactKind
	// Str holds the value for string-typed facts (verbatim, case-folded, or the
	// raw text for any fact without a dedicated parser).
	Str string
	// Int holds the value for decimal/octal-typed facts (size, unix.mode, …).
	Int int
	// Time holds the value for time-typed facts (modify, create, unix.*time).
	Time MLSxTime
}

FactValue is a parsed MLSx fact value. Exactly one shape is populated, selected by Kind. The host maps these onto Ruby values (Integer, Time, String).

type LineReader

type LineReader func() (string, error)

LineReader yields successive raw lines from the control connection. It is the socket seam: returning an error stands in for MRI's EOFError / read failure.

type LoginStep

type LoginStep int

LoginStep is one rung of MRI's login USER → PASS → ACCT ladder.

const (
	// LoginSendPass means the previous reply began with '3', so send PASS next.
	// When the credential for the next step is missing MRI raises FTPReplyError
	// (see LoginNext's error return).
	LoginSendPass LoginStep = iota
	// LoginSendAcct means send ACCT next (the reply still began with '3').
	LoginSendAcct
	// LoginDone means the ladder is complete; the final reply must start with
	// '2' or LoginNext returns FTPReplyError.
	LoginDone
)

func LoginNext

func LoginNext(reply string, afterPass bool) (LoginStep, error)

LoginNext decides the next login action from the latest reply, mirroring the flow in MRI's login. afterPass distinguishes the reply to USER (false) from the reply to PASS (true), so a '3' reply maps to LoginSendPass or LoginSendAcct respectively. A reply that does not begin with '3' ends the ladder: LoginDone, with FTPReplyError when it does not begin with '2'.

type MLSxEntry

type MLSxEntry struct {
	// Facts maps each fact name (already lower-cased, as MRI stores them) to its
	// parsed value. Insertion-order is not preserved (Go map); use FactOrder for
	// the order facts appeared, if needed.
	Facts map[string]FactValue
	// FactOrder lists fact names in the order they appeared in the entry — MRI's
	// Hash preserves insertion order; this exposes the same sequence.
	FactOrder []string
	// Pathname is the entry's pathname (the text after the first space).
	Pathname string
}

MLSxEntry is a parsed MLST/MLSD entry: the facts (keyed by lower-cased name) and the pathname, mirroring Net::FTP::MLSxEntry.

func ParseMLSxEntry

func ParseMLSxEntry(entry string) (MLSxEntry, error)

ParseMLSxEntry parses one MLST/MLSD line into an MLSxEntry, mirroring MRI parse_mlsx_entry:

  • The line is chomped (trailing "\n"/"\r\n"/"\r" removed) then split on the first space into the facts blob and the pathname.
  • A line with no space (no pathname) → FTPProtoError with the raw entry.
  • Each "name=value;" pair is scanned out; the name is lower-cased and its value parsed by the matching FACT_PARSERS entry.

A fact value that fails its parser (an invalid time-val) propagates that FTPProtoError, exactly as the lambda would raise inside MRI.

func (MLSxEntry) Appendable

func (e MLSxEntry) Appendable() bool

Appendable reports MLSxEntry#appendable? (perm includes 'a').

func (MLSxEntry) Creatable

func (e MLSxEntry) Creatable() bool

Creatable reports MLSxEntry#creatable? (perm includes 'c').

func (MLSxEntry) Deletable

func (e MLSxEntry) Deletable() bool

Deletable reports MLSxEntry#deletable? (perm includes 'd').

func (MLSxEntry) DirectoryMakable

func (e MLSxEntry) DirectoryMakable() bool

DirectoryMakable reports MLSxEntry#directory_makable? (perm includes 'm').

func (MLSxEntry) Enterable

func (e MLSxEntry) Enterable() bool

Enterable reports MLSxEntry#enterable? (perm includes 'e').

func (MLSxEntry) IsDirectory

func (e MLSxEntry) IsDirectory() bool

IsDirectory reports whether the entry's type fact is dir, cdir, or pdir (MLSxEntry#directory?).

func (MLSxEntry) IsFile

func (e MLSxEntry) IsFile() bool

IsFile reports whether the entry's type fact is "file" (MLSxEntry#file?).

func (MLSxEntry) Listable

func (e MLSxEntry) Listable() bool

Listable reports MLSxEntry#listable? (perm includes 'l').

func (MLSxEntry) Purgeable

func (e MLSxEntry) Purgeable() bool

Purgeable reports MLSxEntry#purgeable? (perm includes 'p').

func (MLSxEntry) Readable

func (e MLSxEntry) Readable() bool

Readable reports MLSxEntry#readable? (perm includes 'r').

func (MLSxEntry) Renamable

func (e MLSxEntry) Renamable() bool

Renamable reports MLSxEntry#renamable? (perm includes 'f').

func (MLSxEntry) Writable

func (e MLSxEntry) Writable() bool

Writable reports MLSxEntry#writable? (perm includes 'w').

type MLSxTime

type MLSxTime struct {
	Year, Month, Day int
	Hour, Min, Sec   int
	Nsec             int
}

MLSxTime is a parsed MLSx time-val (MRI's TIME_PARSER result, always UTC for MLSx facts). The components match Ruby's Time accessors; Nsec carries the fractional seconds (".fractions".to_r * 1_000_000 → microseconds, here in nanoseconds).

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL