Repository: pkg/sftp
Pull request #430 (2021-04-16 11:24:27)

Encoding SSH filexfer
Let’s try this as round two.

Goals of this sub-package:
* provide a common and safe wire encoding/decoding abstraction
* provide a common packet-level abstraction (packets are the most natural abstraction layer to surface here)
* the wire encoding/decoding abstraction should provide a easy and naïve use-case API (i.e. optimizing reallocations should not be necessary to even use the package)
* packet abstractions should be cleanly separated from the wire encoding/decoding
* `filexfer-02` should be the default implementation (but not the only possible implementation)
* the `openssh` extensions that we currently use should be implemented, in part as an example of how to write extensions
* optimizing reallocations should be possible, but as noted above: way more importantly, it should not be required
* when possible, input reusable buffers should be simple go types (i.e. `[]byte`)

Non-goals:
* thoroughly optimizing the `filexfer-02` use case to the detriment of flexibility. (The reason for this subpackage is to build flexibility, not limit it.)
* optimizations of avoiding reallocations should not be handled in package, but should be the responsibility of the caller.
* avoiding nuances and gotchas of any particular type or strategy of reallocations are not this subpackage’s responsibility (e.g. `[]byte`s in `sync.Pool`s; the mechanics of why these issues exist could be fixed or resolved at any particular time. We do not want to be chasing the bleeding edge here.)


Patch:
diff --git a/.gitignore b/.gitignore
index caf2dca..e1ec837 100644
--- a/.gitignore
+++ b/.gitignore
@@ -5,6 +5,3 @@ server_standalone/server_standalone
 
 examples/*/id_rsa
 examples/*/id_rsa.pub
-
-memprofile.out
-memprofile.svg
diff --git a/Makefile b/Makefile
index 4d3a007..0afad58 100644
--- a/Makefile
+++ b/Makefile
@@ -1,5 +1,3 @@
-.PHONY: integration integration_w_race benchmark
-
 integration:
 	go test -integration -v ./...
 	go test -testserver -v ./...
@@ -16,12 +14,4 @@ integration_w_race:
 	go test -race -testserver -allocator -v ./...
 	go test -race -integration -allocator -testserver -v ./...
 
-COUNT ?= 1
-BENCHMARK_PATTERN ?= "."
-
-benchmark:
-	go test -integration -run=NONE -bench=$(BENCHMARK_PATTERN) -benchmem -count=$(COUNT)
 
-benchmark_w_memprofile:
-	go test -integration -run=NONE -bench=$(BENCHMARK_PATTERN) -benchmem -count=$(COUNT) -memprofile memprofile.out
-	go tool pprof -svg -output=memprofile.svg memprofile.out
diff --git a/attrs.go b/attrs.go
index 7020d3a..9097a4d 100644
--- a/attrs.go
+++ b/attrs.go
@@ -13,10 +13,10 @@ const (
 	sshFileXferAttrUIDGID      = 0x00000002
 	sshFileXferAttrPermissions = 0x00000004
 	sshFileXferAttrACmodTime   = 0x00000008
-	sshFileXferAttrExtended    = 0x80000000
+	sshFileXferAttrExtented    = 0x80000000
 
 	sshFileXferAttrAll = sshFileXferAttrSize | sshFileXferAttrUIDGID | sshFileXferAttrPermissions |
-		sshFileXferAttrACmodTime | sshFileXferAttrExtended
+		sshFileXferAttrACmodTime | sshFileXferAttrExtented
 )
 
 // fileInfo is an artificial type designed to satisfy os.FileInfo.
@@ -119,7 +119,7 @@ func getFileStat(flags uint32, b []byte) (*FileStat, []byte) {
 		fs.Atime, b, _ = unmarshalUint32Safe(b)
 		fs.Mtime, b, _ = unmarshalUint32Safe(b)
 	}
-	if flags&sshFileXferAttrExtended == sshFileXferAttrExtended {
+	if flags&sshFileXferAttrExtented == sshFileXferAttrExtented {
 		var count uint32
 		count, b, _ = unmarshalUint32Safe(b)
 		ext := make([]StatExtended, count)
diff --git a/client.go b/client.go
index afa1978..fee409f 100644
--- a/client.go
+++ b/client.go
@@ -43,10 +43,10 @@ type ClientOption func(*Client) error
 func MaxPacketChecked(size int) ClientOption {
 	return func(c *Client) error {
 		if size < 1 {
-			return errors.New("size must be greater or equal to 1")
+			return errors.Errorf("size must be greater or equal to 1")
 		}
 		if size > 32768 {
-			return errors.New("sizes larger than 32KB might not work with all servers")
+			return errors.Errorf("sizes larger than 32KB might not work with all servers")
 		}
 		c.maxPacket = size
 		return nil
@@ -65,7 +65,7 @@ func MaxPacketChecked(size int) ClientOption {
 func MaxPacketUnchecked(size int) ClientOption {
 	return func(c *Client) error {
 		if size < 1 {
-			return errors.New("size must be greater or equal to 1")
+			return errors.Errorf("size must be greater or equal to 1")
 		}
 		c.maxPacket = size
 		return nil
@@ -90,7 +90,7 @@ func MaxPacket(size int) ClientOption {
 func MaxConcurrentRequestsPerFile(n int) ClientOption {
 	return func(c *Client) error {
 		if n < 1 {
-			return errors.New("n must be greater or equal to 1")
+			return errors.Errorf("n must be greater or equal to 1")
 		}
 		c.maxConcurrentRequests = n
 		return nil
@@ -1284,7 +1284,7 @@ func (f *File) WriteTo(w io.Writer) (written int64, err error) {
 	for {
 		packet, ok := <-cur
 		if !ok {
-			return written, errors.New("sftp.File.WriteTo: unexpectedly closed channel")
+			return written, nil
 		}
 
 		// Because writes are serialized, this will always be the last successfully read byte.
@@ -1637,17 +1637,12 @@ func (f *File) ReadFrom(r io.Reader) (int64, error) {
 		case interface{ Len() int }:
 			remain = int64(r.Len())
 
-		case interface{ Size() int64 }:
-			remain = r.Size()
-
 		case *io.LimitedReader:
 			remain = r.N
 
-		case interface{ Stat() (os.FileInfo, error) }:
-			info, err := r.Stat()
-			if err == nil {
-				remain = info.Size()
-			}
+		case *os.File:
+			// For files, always presume max concurrency.
+			remain = math.MaxInt64
 		}
 
 		if remain > int64(f.c.maxPacket) {
diff --git a/conn.go b/conn.go
index dcca260..952a2be 100644
--- a/conn.go
+++ b/conn.go
@@ -16,6 +16,8 @@ type conn struct {
 	// this is the same allocator used in packet manager
 	alloc      *allocator
 	sync.Mutex // used to serialise writes to sendPacket
+	// sendPacketTest is needed to replicate packet issues in testing
+	sendPacketTest func(w io.Writer, m encoding.BinaryMarshaler) error
 }
 
 // the orderID is used in server mode if the allocator is enabled.
@@ -27,7 +29,9 @@ func (c *conn) recvPacket(orderID uint32) (uint8, []byte, error) {
 func (c *conn) sendPacket(m encoding.BinaryMarshaler) error {
 	c.Lock()
 	defer c.Unlock()
-
+	if c.sendPacketTest != nil {
+		return c.sendPacketTest(c, m)
+	}
 	return sendPacket(c, m)
 }
 
@@ -80,17 +84,14 @@ func (c *clientConn) recv() error {
 		if err != nil {
 			return err
 		}
-		sid, _, err := unmarshalUint32Safe(data)
-		if err != nil {
-			return err
-		}
+		sid, _ := unmarshalUint32(data)
 
 		ch, ok := c.getChannel(sid)
 		if !ok {
 			// This is an unexpected occurrence. Send the error
 			// back to all listeners so that they terminate
 			// gracefully.
-			return errors.Errorf("sid not found: %d", sid)
+			return errors.Errorf("sid not found: %v", sid)
 		}
 
 		ch <- result{typ: typ, data: data}
diff --git a/fuzz.go b/fuzz.go
index 169aebc..2a2cd76 100644
--- a/fuzz.go
+++ b/fuzz.go
@@ -4,12 +4,12 @@ package sftp
 
 import "bytes"
 
-type sinkfuzz struct{}
+type sink struct{}
 
-func (*sinkfuzz) Close() error                { return nil }
-func (*sinkfuzz) Write(p []byte) (int, error) { return len(p), nil }
+func (*sink) Close() error                { return nil }
+func (*sink) Write(p []byte) (int, error) { return len(p), nil }
 
-var devnull = &sinkfuzz{}
+var devnull = &sink{}
 
 // To run: go-fuzz-build && go-fuzz
 func Fuzz(data []byte) int {
diff --git a/go.sum b/go.sum
index c756602..413dc5f 100644
--- a/go.sum
+++ b/go.sum
@@ -15,12 +15,9 @@ golang.org/x/crypto v0.0.0-20201221181555-eec23a3978ad/go.mod h1:jdWPYTVW3xRLrWP
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY=
 golang.org/x/sys v0.0.0-20191026070338-33540a1f6037/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4 h1:myAQVi0cGEoqQVR5POX+8RR2mrocKqNN1hmeMqhX27k=
 golang.org/x/sys v0.0.0-20210119212857-b64e53b001e4/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
-golang.org/x/term v0.0.0-20201117132131-f5c789dd3221 h1:/ZHdbVpdR/jk3g30/d4yUL0JU9kksj8+F/bnQUVLGDM=
 golang.org/x/term v0.0.0-20201117132131-f5c789dd3221/go.mod h1:Nr5EML6q2oocZ2LXRh80K7BxOlk5/8JxuGnuhpl+muw=
 golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
-gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
 gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo=
 gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
diff --git a/internal/encoding/ssh/filexfer/attrs.go b/internal/encoding/ssh/filexfer/attrs.go
new file mode 100644
index 0000000..5ce942e
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/attrs.go
@@ -0,0 +1,265 @@
+package filexfer
+
+// Attributes related flags.
+const (
+	AttrSize        = 1 << iota // SSH_FILEXFER_ATTR_SIZE
+	AttrUIDGID                  // SSH_FILEXFER_ATTR_UIDGID
+	AttrPermissions             // SSH_FILEXFER_ATTR_PERMISSIONS
+	AttrACModTime               // SSH_FILEXFER_ACMODTIME
+
+	AttrExtended = 1 << 31 // SSH_FILEXFER_ATTR_EXTENDED
+)
+
+// Attributes defines the file attributes type defined in draft-ietf-secsh-filexfer-02
+//
+// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-5
+type Attributes struct {
+	Flags uint32
+
+	// AttrSize
+	Size uint64
+
+	// AttrUIDGID
+	UID uint32
+	GID uint32
+
+	// AttrPermissions
+	Permissions FileMode
+
+	// AttrACmodTime
+	ATime uint32
+	MTime uint32
+
+	// AttrExtended
+	ExtendedAttributes []ExtendedAttribute
+}
+
+// Len returns the number of bytes a would marshal into.
+func (a *Attributes) Len() int {
+	length := 4
+
+	if a.Flags&AttrSize != 0 {
+		length += 8
+	}
+
+	if a.Flags&AttrUIDGID != 0 {
+		length += 4 + 4
+	}
+
+	if a.Flags&AttrPermissions != 0 {
+		length += 4
+	}
+
+	if a.Flags&AttrACModTime != 0 {
+		length += 4 + 4
+	}
+
+	if a.Flags&AttrExtended != 0 {
+		length += 4
+
+		for _, ext := range a.ExtendedAttributes {
+			length += ext.Len()
+		}
+	}
+
+	return length
+}
+
+// MarshalInto marshals e onto the end of the given Buffer.
+func (a *Attributes) MarshalInto(b *Buffer) {
+	b.AppendUint32(a.Flags)
+
+	if a.Flags&AttrSize != 0 {
+		b.AppendUint64(a.Size)
+	}
+
+	if a.Flags&AttrUIDGID != 0 {
+		b.AppendUint32(a.UID)
+		b.AppendUint32(a.GID)
+	}
+
+	if a.Flags&AttrPermissions != 0 {
+		b.AppendUint32(uint32(a.Permissions))
+	}
+
+	if a.Flags&AttrACModTime != 0 {
+		b.AppendUint32(a.ATime)
+		b.AppendUint32(a.MTime)
+	}
+
+	if a.Flags&AttrExtended != 0 {
+		b.AppendUint32(uint32(len(a.ExtendedAttributes)))
+
+		for _, ext := range a.ExtendedAttributes {
+			ext.MarshalInto(b)
+		}
+	}
+}
+
+// MarshalBinary returns a as the binary encoding of a.
+func (a *Attributes) MarshalBinary() ([]byte, error) {
+	buf := NewBuffer(make([]byte, a.Len()))
+	a.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom unmarshals an Attributes from the given Buffer into e.
+//
+// NOTE: The values of fields not covered in the a.Flags are explicitly undefined.
+func (a *Attributes) UnmarshalFrom(b *Buffer) (err error) {
+	if a.Flags, err = b.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	// Short-circuit dummy attributes.
+	if a.Flags == 0 {
+		return nil
+	}
+
+	if a.Flags&AttrSize != 0 {
+		if a.Size, err = b.ConsumeUint64(); err != nil {
+			return err
+		}
+	}
+
+	if a.Flags&AttrUIDGID != 0 {
+		if a.UID, err = b.ConsumeUint32(); err != nil {
+			return err
+		}
+
+		if a.GID, err = b.ConsumeUint32(); err != nil {
+			return err
+		}
+	}
+
+	if a.Flags&AttrPermissions != 0 {
+		m, err := b.ConsumeUint32()
+		if err != nil {
+			return err
+		}
+
+		a.Permissions = FileMode(m)
+	}
+
+	if a.Flags&AttrACModTime != 0 {
+		if a.ATime, err = b.ConsumeUint32(); err != nil {
+			return err
+		}
+
+		if a.MTime, err = b.ConsumeUint32(); err != nil {
+			return err
+		}
+	}
+
+	if a.Flags&AttrExtended != 0 {
+		count, err := b.ConsumeUint32()
+		if err != nil {
+			return err
+		}
+
+		a.ExtendedAttributes = make([]ExtendedAttribute, count)
+		for i := range a.ExtendedAttributes {
+			a.ExtendedAttributes[i].UnmarshalFrom(b)
+		}
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the binary encoding of Attributes into e.
+func (a *Attributes) UnmarshalBinary(data []byte) error {
+	return a.UnmarshalFrom(NewBuffer(data))
+}
+
+// ExtendedAttribute defines the extended file attribute type defined in draft-ietf-secsh-filexfer-02
+//
+// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-5
+type ExtendedAttribute struct {
+	Type string
+	Data string
+}
+
+// Len returns the number of bytes e would marshal into.
+func (e *ExtendedAttribute) Len() int {
+	return 4 + len(e.Type) + 4 + len(e.Data)
+}
+
+// MarshalInto marshals e onto the end of the given Buffer.
+func (e *ExtendedAttribute) MarshalInto(b *Buffer) {
+	b.AppendString(e.Type)
+	b.AppendString(e.Data)
+}
+
+// MarshalBinary returns e as the binary encoding of e.
+func (e *ExtendedAttribute) MarshalBinary() ([]byte, error) {
+	buf := NewBuffer(make([]byte, e.Len()))
+	e.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom unmarshals an ExtendedAattribute from the given Buffer into e.
+func (e *ExtendedAttribute) UnmarshalFrom(b *Buffer) (err error) {
+	if e.Type, err = b.ConsumeString(); err != nil {
+		return err
+	}
+
+	if e.Data, err = b.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the binary encoding of ExtendedAttribute into e.
+func (e *ExtendedAttribute) UnmarshalBinary(data []byte) error {
+	return e.UnmarshalFrom(NewBuffer(data))
+}
+
+// NameEntry implements the SSH_FXP_NAME repeated data type from draft-ietf-secsh-filexfer-02
+//
+// This type is incompatible with versions 4 or higher.
+type NameEntry struct {
+	Filename string
+	Longname string
+	Attrs    Attributes
+}
+
+// Len returns the number of bytes e would marshal into.
+func (e *NameEntry) Len() int {
+	return 4 + len(e.Filename) + 4 + len(e.Longname) + e.Attrs.Len()
+}
+
+// MarshalInto marshals e onto the end of the given Buffer.
+func (e *NameEntry) MarshalInto(b *Buffer) {
+	b.AppendString(e.Filename)
+	b.AppendString(e.Longname)
+
+	e.Attrs.MarshalInto(b)
+}
+
+// MarshalBinary returns e as the binary encoding of e.
+func (e *NameEntry) MarshalBinary() ([]byte, error) {
+	buf := NewBuffer(make([]byte, e.Len()))
+	e.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom unmarshals an NameEntry from the given Buffer into e.
+//
+// NOTE: The values of fields not covered in the a.Flags are explicitly undefined.
+func (e *NameEntry) UnmarshalFrom(b *Buffer) (err error) {
+	if e.Filename, err = b.ConsumeString(); err != nil {
+		return err
+	}
+
+	if e.Longname, err = b.ConsumeString(); err != nil {
+		return err
+	}
+
+	return e.Attrs.UnmarshalFrom(b)
+}
+
+// UnmarshalBinary decodes the binary encoding of NameEntry into e.
+func (e *NameEntry) UnmarshalBinary(data []byte) error {
+	return e.UnmarshalFrom(NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/buffer.go b/internal/encoding/ssh/filexfer/buffer.go
new file mode 100644
index 0000000..a608603
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/buffer.go
@@ -0,0 +1,293 @@
+package filexfer
+
+import (
+	"encoding/binary"
+	"errors"
+)
+
+// Various encoding errors.
+var (
+	ErrShortPacket = errors.New("packet too short")
+	ErrLongPacket  = errors.New("packet too long")
+)
+
+// Buffer wraps up the various encoding details of the SSH format.
+//
+// Data types are encoded as per section 4 from https://tools.ietf.org/html/draft-ietf-secsh-architecture-09#page-8
+type Buffer struct {
+	b   []byte
+	off int
+}
+
+// NewBuffer creates and initializes a new buffer using buf as its initial contents.
+// The new buffer takes ownership of buf, and the caller should not use buf after this call.
+//
+// In most cases, new(Buffer) (or just declaring a Buffer variable) is sufficient to initialize a Buffer.
+func NewBuffer(buf []byte) *Buffer {
+	return &Buffer{
+		b: buf,
+	}
+}
+
+// NewMarshalBuffer creates a new Buffer ready to start marshaling a Packet into.
+// It preallocates enough space for uint32(length), uint8(type), uint32(request-id) and size more bytes.
+func NewMarshalBuffer(size int) *Buffer {
+	return NewBuffer(make([]byte, 4+1+4+size))
+}
+
+// Bytes returns a slice of length b.Len() holding the unconsumed bytes in the Buffer.
+// The slice is valid for use only until the next buffer modification
+// (that is, only until the next call to an Append or Consume method).
+func (b *Buffer) Bytes() []byte {
+	return b.b[b.off:]
+}
+
+// Len returns the number of unconsumed bytes in the buffer.
+func (b *Buffer) Len() int { return len(b.b) - b.off }
+
+// Cap returns the capacity of the buffer’s underlying byte slice,
+// that is, the total space allocated for the buffer’s data.
+func (b *Buffer) Cap() int { return cap(b.b) }
+
+// Reset resets the buffer to be empty, but it retains the underlying storage for use by future Appends.
+func (b *Buffer) Reset() {
+	b.b = b.b[:0]
+	b.off = 0
+}
+
+// StartPacket resets and initializes the buffer to be ready to start marshaling a packet into.
+// It truncates the buffer, reserves space for uint32(length), then appends the given packetType and requestID.
+func (b *Buffer) StartPacket(packetType PacketType, requestID uint32) {
+	b.b, b.off = append(b.b[:0], make([]byte, 4)...), 0
+
+	b.AppendUint8(uint8(packetType))
+	b.AppendUint32(requestID)
+}
+
+// Packet finalizes the packet started from StartPacket.
+// It is expected that this will end the ownership of the underlying byte-slice,
+// and so the returned byte-slices may be reused the same as any other byte-slice,
+// the caller should not use this buffer after this call.
+//
+// It writes the packet body length into the first four bytes of the buffer in network byte order (big endian).
+// The packet body length is the length of this buffer less the 4-byte length itself, plus the length of payload.
+//
+// It is assumed that no Consume methods have been called on this buffer,
+// and so it returns the whole underlying slice.
+func (b *Buffer) Packet(payload []byte) (header, payloadPassThru []byte, err error) {
+	b.PutLength(len(b.b) - 4 + len(payload))
+
+	return b.b, payload, nil
+}
+
+// ConsumeUint8 consumes a single byte from the buffer.
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeUint8() (uint8, error) {
+	if b.Len() < 1 {
+		return 0, ErrShortPacket
+	}
+
+	var v uint8
+	v, b.off = b.b[b.off], b.off+1
+	return v, nil
+}
+
+// AppendUint8 appends a single byte into the buffer.
+func (b *Buffer) AppendUint8(v uint8) {
+	b.b = append(b.b, v)
+}
+
+// ConsumeBool consumes a single byte from the buffer, and returns true if that byte is non-zero.
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeBool() (bool, error) {
+	v, err := b.ConsumeUint8()
+	if err != nil {
+		return false, err
+	}
+
+	return v != 0, nil
+}
+
+// AppendBool appends a single bool into the buffer.
+// It encodes it as a single byte, with false as 0, and true as 1.
+func (b *Buffer) AppendBool(v bool) {
+	if v {
+		b.AppendUint8(1)
+	} else {
+		b.AppendUint8(0)
+	}
+}
+
+// ConsumeUint16 consumes a single uint16 from the buffer, in network byte order (big-endian).
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeUint16() (uint16, error) {
+	if b.Len() < 2 {
+		return 0, ErrShortPacket
+	}
+
+	v := binary.BigEndian.Uint16(b.b[b.off:])
+	b.off += 2
+	return v, nil
+}
+
+// AppendUint16 appends single uint16 into the buffer, in network byte order (big-endian).
+func (b *Buffer) AppendUint16(v uint16) {
+	b.b = append(b.b,
+		byte(v>>8),
+		byte(v>>0),
+	)
+}
+
+// unmarshalUint32 is used internally to read the packet length.
+// It is unsafe, and so not exported.
+// Even within this package, its use should be avoided.
+func unmarshalUint32(b []byte) uint32 {
+	return binary.BigEndian.Uint32(b[:4])
+}
+
+// ConsumeUint32 consumes a single uint32 from the buffer, in network byte order (big-endian).
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeUint32() (uint32, error) {
+	if b.Len() < 4 {
+		return 0, ErrShortPacket
+	}
+
+	v := binary.BigEndian.Uint32(b.b[b.off:])
+	b.off += 4
+	return v, nil
+}
+
+// AppendUint32 appends a single uint32 into the buffer, in network byte order (big-endian).
+func (b *Buffer) AppendUint32(v uint32) {
+	b.b = append(b.b,
+		byte(v>>24),
+		byte(v>>16),
+		byte(v>>8),
+		byte(v>>0),
+	)
+}
+
+// ConsumeUint64 consumes a single uint64 from the buffer, in network byte order (big-endian).
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeUint64() (uint64, error) {
+	if b.Len() < 8 {
+		return 0, ErrShortPacket
+	}
+
+	v := binary.BigEndian.Uint64(b.b[b.off:])
+	b.off += 8
+	return v, nil
+}
+
+// AppendUint64 appends a single uint64 into the buffer, in network byte order (big-endian).
+func (b *Buffer) AppendUint64(v uint64) {
+	b.b = append(b.b,
+		byte(v>>56),
+		byte(v>>48),
+		byte(v>>40),
+		byte(v>>32),
+		byte(v>>24),
+		byte(v>>16),
+		byte(v>>8),
+		byte(v>>0),
+	)
+}
+
+// ConsumeInt64 consumes a single int64 from the buffer, in network byte order (big-endian) with two’s complement.
+// If the buffer does not have enough data, it will return ErrShortPacket.
+func (b *Buffer) ConsumeInt64() (int64, error) {
+	u, err := b.ConsumeUint64()
+	if err != nil {
+		return 0, err
+	}
+
+	return int64(u), err
+}
+
+// AppendInt64 appends a single int64 into the buffer, in network byte order (big-endian) with two’s complement.
+func (b *Buffer) AppendInt64(v int64) {
+	b.AppendUint64(uint64(v))
+}
+
+// ConsumeByteSlice consumes a single string of raw binary data from the buffer.
+// A string is a uint32 length, followed by that number of raw bytes.
+// If the buffer does not have enough data, or defines a length larger than available, it will return ErrShortPacket.
+//
+// The returned slice aliases the buffer contents, and is valid only as long as the buffer is not reused
+// (that is, only until the next call to Reset, PutLength, StartPacket, or UnmarshalBinary).
+//
+// In no case will any Consume calls return overlapping slice aliases,
+// and Append calls are guaranteed to not disturb this slice alias.
+func (b *Buffer) ConsumeByteSlice() ([]byte, error) {
+	length, err := b.ConsumeUint32()
+	if err != nil {
+		return nil, err
+	}
+
+	if b.Len() < int(length) {
+		return nil, ErrShortPacket
+	}
+
+	v := b.b[b.off:]
+	if len(v) > int(length) {
+		v = v[:length:length]
+	}
+	b.off += int(length)
+	return v, nil
+}
+
+// AppendByteSlice appends a single string of raw binary data into the buffer.
+// A string is a uint32 length, followed by that number of raw bytes.
+func (b *Buffer) AppendByteSlice(v []byte) {
+	b.AppendUint32(uint32(len(v)))
+	b.b = append(b.b, v...)
+}
+
+// ConsumeString consumes a single string of binary data from the buffer.
+// A string is a uint32 length, followed by that number of raw bytes.
+// If the buffer does not have enough data, or defines a length larger than available, it will return ErrShortPacket.
+//
+// NOTE: Go implicitly assumes that strings contain UTF-8 encoded data.
+// All caveats on using arbitrary binary data in Go strings applies.
+func (b *Buffer) ConsumeString() (string, error) {
+	v, err := b.ConsumeByteSlice()
+	if err != nil {
+		return "", err
+	}
+
+	return string(v), nil
+}
+
+// AppendString appends a single string of binary data into the buffer.
+// A string is a uint32 length, followed by that number of raw bytes.
+func (b *Buffer) AppendString(v string) {
+	b.AppendByteSlice([]byte(v))
+}
+
+// PutLength writes the given size into the first four bytes of the buffer in network byte order (big endian).
+func (b *Buffer) PutLength(size int) {
+	if len(b.b) < 4 {
+		b.b = append(b.b, make([]byte, 4-len(b.b))...)
+	}
+
+	binary.BigEndian.PutUint32(b.b, uint32(size))
+}
+
+// MarshalBinary returns a clone of the full internal buffer.
+func (b *Buffer) MarshalBinary() ([]byte, error) {
+	clone := make([]byte, len(b.b))
+	n := copy(clone, b.b)
+	return clone[:n], nil
+}
+
+// UnmarshalBinary sets the internal buffer of b to be a clone of data, and zeros the internal offset.
+func (b *Buffer) UnmarshalBinary(data []byte) error {
+	if grow := len(data) - len(b.b); grow > 0 {
+		b.b = append(b.b, make([]byte, grow)...)
+	}
+
+	n := copy(b.b, data)
+	b.b = b.b[:n]
+	b.off = 0
+	return nil
+}
diff --git a/internal/encoding/ssh/filexfer/extended_packets.go b/internal/encoding/ssh/filexfer/extended_packets.go
new file mode 100644
index 0000000..85ccce0
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extended_packets.go
@@ -0,0 +1,132 @@
+package filexfer
+
+import (
+	"encoding"
+	"sync"
+)
+
+// ExtendedData aliases the untyped interface composition of encoding.BinaryMarshaler and encoding.BinaryUnmarshaler.
+type ExtendedData = interface {
+	encoding.BinaryMarshaler
+	encoding.BinaryUnmarshaler
+}
+
+// ExtendedDataConstructor defines a function that returns a new(ArbitraryExtendedPacket).
+type ExtendedDataConstructor func() ExtendedData
+
+var extendedPacketTypes = struct {
+	mu           sync.RWMutex
+	constructors map[string]ExtendedDataConstructor
+}{
+	constructors: make(map[string]ExtendedDataConstructor),
+}
+
+// RegisterExtendedPacketType defines a specific ExtendedDataConstructor for the given extension string.
+func RegisterExtendedPacketType(extension string, constructor ExtendedDataConstructor) {
+	extendedPacketTypes.mu.Lock()
+	defer extendedPacketTypes.mu.Unlock()
+
+	if _, exist := extendedPacketTypes.constructors[extension]; exist {
+		panic("encoding/ssh/filexfer: multiple registration of extended packet type " + extension)
+	}
+
+	extendedPacketTypes.constructors[extension] = constructor
+}
+
+func newExtendedPacket(extension string) ExtendedData {
+	extendedPacketTypes.mu.RLock()
+	defer extendedPacketTypes.mu.RUnlock()
+
+	if f := extendedPacketTypes.constructors[extension]; f != nil {
+		return f()
+	}
+
+	return new(Buffer)
+}
+
+// ExtendedPacket defines the SSH_FXP_CLOSE packet.
+type ExtendedPacket struct {
+	ExtendedRequest string
+
+	Data ExtendedData
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+//
+// The Data is marshaled into binary, and returned as the payload.
+func (p *ExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.ExtendedRequest) // string(extended-request)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeExtended, reqid)
+	buf.AppendString(p.ExtendedRequest)
+
+	if p.Data != nil {
+		payload, err = p.Data.MarshalBinary()
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+//
+// If p.Data is nil, and the extension has been registered, a new type will be made from the registration.
+// If the extension has not been registered, then a new Buffer will be allocated.
+// Then the request-specific-data will be unmarshaled from the rest of the buffer.
+func (p *ExtendedPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.ExtendedRequest, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.Data == nil {
+		p.Data = newExtendedPacket(p.ExtendedRequest)
+	}
+
+	return p.Data.UnmarshalBinary(buf.Bytes())
+}
+
+// ExtendedReplyPacket defines the SSH_FXP_CLOSE packet.
+type ExtendedReplyPacket struct {
+	Data ExtendedData
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+//
+// The Data is marshaled into binary, and returned as the payload.
+func (p *ExtendedReplyPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		buf = NewMarshalBuffer(0)
+	}
+
+	buf.StartPacket(PacketTypeExtendedReply, reqid)
+
+	if p.Data != nil {
+		payload, err = p.Data.MarshalBinary()
+		if err != nil {
+			return nil, nil, err
+		}
+	}
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+//
+// If p.Data is nil, and there is request-specific-data,
+// then the request-specific-data will be wrapped in a Buffer and assigned to p.Data.
+func (p *ExtendedReplyPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Data == nil {
+		p.Data = new(Buffer)
+	}
+
+	return p.Data.UnmarshalBinary(buf.Bytes())
+}
diff --git a/internal/encoding/ssh/filexfer/extensions.go b/internal/encoding/ssh/filexfer/extensions.go
new file mode 100644
index 0000000..23980b1
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extensions.go
@@ -0,0 +1,46 @@
+package filexfer
+
+// ExtensionPair defines the extension-pair type defined in draft-ietf-secsh-filexfer-13.
+// This type is backwards-compatible with how draft-ietf-secsh-filexfer-02 defines extensions.
+//
+// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-4.2
+type ExtensionPair struct {
+	Name string
+	Data string
+}
+
+// Len returns the number of bytes e would marshal into.
+func (e *ExtensionPair) Len() int {
+	return 4 + len(e.Name) + 4 + len(e.Data)
+}
+
+// MarshalInto marshals e onto the end of the given Buffer.
+func (e *ExtensionPair) MarshalInto(buf *Buffer) {
+	buf.AppendString(e.Name)
+	buf.AppendString(e.Data)
+}
+
+// MarshalBinary returns e as the binary encoding of e.
+func (e *ExtensionPair) MarshalBinary() ([]byte, error) {
+	buf := NewBuffer(make([]byte, e.Len()))
+	e.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom unmarshals an ExtensionPair from the given Buffer into e.
+func (e *ExtensionPair) UnmarshalFrom(buf *Buffer) (err error) {
+	if e.Name, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if e.Data, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the binary encoding of ExtensionPair into e.
+func (e *ExtensionPair) UnmarshalBinary(data []byte) error {
+	return e.UnmarshalFrom(NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/filexfer.go b/internal/encoding/ssh/filexfer/filexfer.go
new file mode 100644
index 0000000..e8b2c34
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/filexfer.go
@@ -0,0 +1,34 @@
+// Package filexfer implements the wire encoding for secsh-filexfer as described in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02
+package filexfer
+
+// Packet defines the behavior of an SFTP packet.
+type Packet interface {
+	// MarshalPacket is the primary intended way to encode a packet.
+	// The request-id for the packet is set from reqid.
+	//
+	// An optional buffer may be given in b.
+	// If the buffer has a minimum capacity, it shall be truncated and used to marshal the header into.
+	// The minimum capacity for the packet must be a constant expression, and should be at least 9.
+	//
+	// It shall return the main body of the encoded packet in header,
+	// and may optionally return an additional payload to be written immediately after the header.
+	//
+	// It shall encode in the first 4-bytes of the header the proper length of the rest of the header+payload.
+	MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error)
+
+	// UnmarshalPacketBody decodes a packet body from the given Buffer.
+	// It is assumed that the common header values of the length, type and request-id have already been consumed.
+	UnmarshalPacketBody(buf *Buffer) error
+}
+
+// ComposePacket converts returns from MarshalPacket into an equivalent call to MarshalBinary.
+func ComposePacket(header, payload []byte, err error) ([]byte, error) {
+	return append(header, payload...), err
+}
+
+// Default length values,
+// Defined in draft-ietf-secsh-filexfer-02 section 3.
+const (
+	DefaultMaxPacketLength = 34000
+	DefaultMaxDataLength   = 32768
+)
diff --git a/internal/encoding/ssh/filexfer/fx.go b/internal/encoding/ssh/filexfer/fx.go
new file mode 100644
index 0000000..3eeac65
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fx.go
@@ -0,0 +1,137 @@
+package filexfer
+
+import (
+	"fmt"
+)
+
+// Status defines the SFTP error codes used in SSH_FXP_STATUS response packets.
+type Status uint32
+
+// Defines the various SSH_FX_* values.
+const (
+	// see draft-ietf-secsh-filexfer-02
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-7
+	StatusOK = Status(iota)
+	StatusEOF
+	StatusNoSuchFile
+	StatusPermissionDenied
+	StatusFailure
+	StatusBadMessage
+	StatusNoConnection
+	StatusConnectionLost
+	StatusOPUnsupported
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-03#section-7
+	StatusV4InvalidHandle
+	StatusV4NoSuchPath
+	StatusV4FileAlreadyExists
+	StatusV4WriteProtect
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-04#section-7
+	StatusV4NoMedia
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-05#section-7
+	StatusV5NoSpaceOnFilesystem
+	StatusV5QuotaExceeded
+	StatusV5UnknownPrincipal
+	StatusV5LockConflict
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-06#section-8
+	StatusV6DirNotEmpty
+	StatusV6NotADirectory
+	StatusV6InvalidFilename
+	StatusV6LinkLoop
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-07#section-8
+	StatusV6CannotDelete
+	StatusV6InvalidParameter
+	StatusV6FileIsADirectory
+	StatusV6ByteRangeLockConflict
+	StatusV6ByteRangeLockRefused
+	StatusV6DeletePending
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-08#section-8.1
+	StatusV6FileCorrupt
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-10#section-9.1
+	StatusV6OwnerInvalid
+	StatusV6GroupInvalid
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.1
+	StatusV6NoMatchingByteRangeLock
+)
+
+func (f Status) Error() string {
+	return f.String()
+}
+
+func (f Status) String() string {
+	switch f {
+	case StatusOK:
+		return "SSH_FX_OK"
+	case StatusEOF:
+		return "SSH_FX_EOF"
+	case StatusNoSuchFile:
+		return "SSH_FX_NO_SUCH_FILE"
+	case StatusPermissionDenied:
+		return "SSH_FX_PERMISSION_DENIED"
+	case StatusFailure:
+		return "SSH_FX_FAILURE"
+	case StatusBadMessage:
+		return "SSH_FX_BAD_MESSAGE"
+	case StatusNoConnection:
+		return "SSH_FX_NO_CONNECTION"
+	case StatusConnectionLost:
+		return "SSH_FX_CONNECTION_LOST"
+	case StatusOPUnsupported:
+		return "SSH_FX_OP_UNSUPPORTED"
+	case StatusV4InvalidHandle:
+		return "SSH_FX_INVALID_HANDLE"
+	case StatusV4NoSuchPath:
+		return "SSH_FX_NO_SUCH_PATH"
+	case StatusV4FileAlreadyExists:
+		return "SSH_FX_FILE_ALREADY_EXISTS"
+	case StatusV4WriteProtect:
+		return "SSH_FX_WRITE_PROTECT"
+	case StatusV4NoMedia:
+		return "SSH_FX_NO_MEDIA"
+	case StatusV5NoSpaceOnFilesystem:
+		return "SSH_FX_NO_SPACE_ON_FILESYSTEM"
+	case StatusV5QuotaExceeded:
+		return "SSH_FX_QUOTA_EXCEEDED"
+	case StatusV5UnknownPrincipal:
+		return "SSH_FX_UNKNOWN_PRINCIPAL"
+	case StatusV5LockConflict:
+		return "SSH_FX_LOCK_CONFLICT"
+	case StatusV6DirNotEmpty:
+		return "SSH_FX_DIR_NOT_EMPTY"
+	case StatusV6NotADirectory:
+		return "SSH_FX_NOT_A_DIRECTORY"
+	case StatusV6InvalidFilename:
+		return "SSH_FX_INVALID_FILENAME"
+	case StatusV6LinkLoop:
+		return "SSH_FX_LINK_LOOP"
+	case StatusV6CannotDelete:
+		return "SSH_FX_CANNOT_DELETE"
+	case StatusV6InvalidParameter:
+		return "SSH_FX_INVALID_PARAMETER"
+	case StatusV6FileIsADirectory:
+		return "SSH_FX_FILE_IS_A_DIRECTORY"
+	case StatusV6ByteRangeLockConflict:
+		return "SSH_FX_BYTE_RANGE_LOCK_CONFLICT"
+	case StatusV6ByteRangeLockRefused:
+		return "SSH_FX_BYTE_RANGE_LOCK_REFUSED"
+	case StatusV6DeletePending:
+		return "SSH_FX_DELETE_PENDING"
+	case StatusV6FileCorrupt:
+		return "SSH_FX_FILE_CORRUPT"
+	case StatusV6OwnerInvalid:
+		return "SSH_FX_OWNER_INVALID"
+	case StatusV6GroupInvalid:
+		return "SSH_FX_GROUP_INVALID"
+	case StatusV6NoMatchingByteRangeLock:
+		return "SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK"
+	default:
+		return fmt.Sprintf("SSH_FX_UNKNOWN(%d)", f)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/fxp.go b/internal/encoding/ssh/filexfer/fxp.go
new file mode 100644
index 0000000..15caf6d
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fxp.go
@@ -0,0 +1,124 @@
+package filexfer
+
+import (
+	"fmt"
+)
+
+// PacketType defines the various SFTP packet types.
+type PacketType uint8
+
+// Request packet types.
+const (
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3
+	PacketTypeInit = PacketType(iota + 1)
+	PacketTypeVersion
+	PacketTypeOpen
+	PacketTypeClose
+	PacketTypeRead
+	PacketTypeWrite
+	PacketTypeLStat
+	PacketTypeFStat
+	PacketTypeSetstat
+	PacketTypeFSetstat
+	PacketTypeOpenDir
+	PacketTypeReadDir
+	PacketTypeRemove
+	PacketTypeMkdir
+	PacketTypeRmdir
+	PacketTypeRealPath
+	PacketTypeStat
+	PacketTypeRename
+	PacketTypeReadLink
+	PacketTypeSymlink
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-07#section-3.3
+	PacketTypeV6Link
+
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-08#section-3.3
+	PacketTypeV6Block
+	PacketTypeV6Unblock
+)
+
+// Response packet types.
+const (
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3
+	PacketTypeStatus = PacketType(iota + 101)
+	PacketTypeHandle
+	PacketTypeData
+	PacketTypeName
+	PacketTypeAttrs
+)
+
+// Extended packet types.
+const (
+	// https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3
+	PacketTypeExtended = PacketType(iota + 200)
+	PacketTypeExtendedReply
+)
+
+func (f PacketType) String() string {
+	switch f {
+	case PacketTypeInit:
+		return "SSH_FXP_INIT"
+	case PacketTypeVersion:
+		return "SSH_FXP_VERSION"
+	case PacketTypeOpen:
+		return "SSH_FXP_OPEN"
+	case PacketTypeClose:
+		return "SSH_FXP_CLOSE"
+	case PacketTypeRead:
+		return "SSH_FXP_READ"
+	case PacketTypeWrite:
+		return "SSH_FXP_WRITE"
+	case PacketTypeLStat:
+		return "SSH_FXP_LSTAT"
+	case PacketTypeFStat:
+		return "SSH_FXP_FSTAT"
+	case PacketTypeSetstat:
+		return "SSH_FXP_SETSTAT"
+	case PacketTypeFSetstat:
+		return "SSH_FXP_FSETSTAT"
+	case PacketTypeOpenDir:
+		return "SSH_FXP_OPENDIR"
+	case PacketTypeReadDir:
+		return "SSH_FXP_READDIR"
+	case PacketTypeRemove:
+		return "SSH_FXP_REMOVE"
+	case PacketTypeMkdir:
+		return "SSH_FXP_MKDIR"
+	case PacketTypeRmdir:
+		return "SSH_FXP_RMDIR"
+	case PacketTypeRealPath:
+		return "SSH_FXP_REALPATH"
+	case PacketTypeStat:
+		return "SSH_FXP_STAT"
+	case PacketTypeRename:
+		return "SSH_FXP_RENAME"
+	case PacketTypeReadLink:
+		return "SSH_FXP_READLINK"
+	case PacketTypeSymlink:
+		return "SSH_FXP_SYMLINK"
+	case PacketTypeV6Link:
+		return "SSH_FXP_LINK"
+	case PacketTypeV6Block:
+		return "SSH_FXP_BLOCK"
+	case PacketTypeV6Unblock:
+		return "SSH_FXP_UNBLOCK"
+	case PacketTypeStatus:
+		return "SSH_FXP_STATUS"
+	case PacketTypeHandle:
+		return "SSH_FXP_HANDLE"
+	case PacketTypeData:
+		return "SSH_FXP_DATA"
+	case PacketTypeName:
+		return "SSH_FXP_NAME"
+	case PacketTypeAttrs:
+		return "SSH_FXP_ATTRS"
+	case PacketTypeExtended:
+		return "SSH_FXP_EXTENDED"
+	case PacketTypeExtendedReply:
+		return "SSH_FXP_EXTENDED_REPLY"
+	default:
+		return fmt.Sprintf("SSH_FXP_UNKNOWN(%d)", f)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/handle_packets.go b/internal/encoding/ssh/filexfer/handle_packets.go
new file mode 100644
index 0000000..33670dc
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/handle_packets.go
@@ -0,0 +1,204 @@
+package filexfer
+
+// ClosePacket defines the SSH_FXP_CLOSE packet.
+type ClosePacket struct {
+	Handle string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *ClosePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Handle) // string(handle)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeClose, reqid)
+	buf.AppendString(p.Handle)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *ClosePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// ReadPacket defines the SSH_FXP_READ packet.
+type ReadPacket struct {
+	Handle string
+	Offset uint64
+	Len    uint32
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *ReadPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// string(handle) + uint64(offset) + uint32(len)
+		size := 4 + len(p.Handle) + 8 + 4
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeRead, reqid)
+	buf.AppendString(p.Handle)
+	buf.AppendUint64(p.Offset)
+	buf.AppendUint32(p.Len)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *ReadPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.Offset, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+
+	if p.Len, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// WritePacket defines the SSH_FXP_WRITE packet.
+type WritePacket struct {
+	Handle string
+	Offset uint64
+	Data   []byte
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *WritePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// string(handle) + uint64(offset) + uint32(len(data)); data content in payload
+		size := 4 + len(p.Handle) + 8 + 4
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeWrite, reqid)
+	buf.AppendString(p.Handle)
+	buf.AppendUint64(p.Offset)
+	buf.AppendUint32(uint32(len(p.Data)))
+
+	return buf.Packet(p.Data)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *WritePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.Offset, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+
+	if p.Data, err = buf.ConsumeByteSlice(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// FStatPacket defines the SSH_FXP_FSTAT packet.
+type FStatPacket struct {
+	Handle string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *FStatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Handle) // string(handle)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeFStat, reqid)
+	buf.AppendString(p.Handle)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *FStatPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// FSetstatPacket defines the SSH_FXP_FSETSTAT packet.
+type FSetstatPacket struct {
+	Handle string
+	Attrs  Attributes
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *FSetstatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Handle) + p.Attrs.Len() // string(handle) + ATTRS(attrs)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeFSetstat, reqid)
+	buf.AppendString(p.Handle)
+
+	p.Attrs.MarshalInto(buf)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *FSetstatPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return p.Attrs.UnmarshalFrom(buf)
+}
+
+// ReadDirPacket defines the SSH_FXP_READDIR packet.
+type ReadDirPacket struct {
+	Handle string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *ReadDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Handle) // string(handle)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeReadDir, reqid)
+	buf.AppendString(p.Handle)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *ReadDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/encoding/ssh/filexfer/init_packets.go b/internal/encoding/ssh/filexfer/init_packets.go
new file mode 100644
index 0000000..b3e99dc
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/init_packets.go
@@ -0,0 +1,99 @@
+package filexfer
+
+// InitPacket defines the SSH_FXP_INIT packet.
+type InitPacket struct {
+	Version    uint32
+	Extensions []*ExtensionPair
+}
+
+// MarshalBinary returns p as the binary encoding of p.
+func (p *InitPacket) MarshalBinary() ([]byte, error) {
+	size := 1 + 4 // byte(type) + uint32(version)
+
+	for _, ext := range p.Extensions {
+		size += ext.Len()
+	}
+
+	b := NewBuffer(make([]byte, 4, 4+size))
+	b.AppendUint8(uint8(PacketTypeInit))
+	b.AppendUint32(p.Version)
+
+	for _, ext := range p.Extensions {
+		ext.MarshalInto(b)
+	}
+
+	b.PutLength(size)
+
+	return b.Bytes(), nil
+}
+
+// UnmarshalBinary unmarshals a full raw packet out of the given data.
+// It is assumed that the uint32(length) has already been consumed to receive the data.
+// It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into.
+func (p *InitPacket) UnmarshalBinary(data []byte) (err error) {
+	buf := NewBuffer(data)
+
+	if p.Version, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	for buf.Len() > 0 {
+		var ext ExtensionPair
+		if err := ext.UnmarshalFrom(buf); err != nil {
+			return err
+		}
+
+		p.Extensions = append(p.Extensions, &ext)
+	}
+
+	return nil
+}
+
+// VersionPacket defines the SSH_FXP_VERSION packet.
+type VersionPacket struct {
+	Version    uint32
+	Extensions []*ExtensionPair
+}
+
+// MarshalBinary returns p as the binary encoding of p.
+func (p *VersionPacket) MarshalBinary() ([]byte, error) {
+	size := 1 + 4 // byte(type) + uint32(version)
+
+	for _, ext := range p.Extensions {
+		size += ext.Len()
+	}
+
+	b := NewBuffer(make([]byte, 4, 4+size))
+	b.AppendUint8(uint8(PacketTypeInit))
+	b.AppendUint32(p.Version)
+
+	for _, ext := range p.Extensions {
+		ext.MarshalInto(b)
+	}
+
+	b.PutLength(size)
+
+	return b.Bytes(), nil
+}
+
+// UnmarshalBinary unmarshals a full raw packet out of the given data.
+// It is assumed that the uint32(length) has already been consumed to receive the data.
+// It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into.
+func (p *VersionPacket) UnmarshalBinary(data []byte) (err error) {
+	buf := NewBuffer(data)
+
+	if p.Version, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	for buf.Len() > 0 {
+		var ext ExtensionPair
+		if err := ext.UnmarshalFrom(buf); err != nil {
+			return err
+		}
+
+		p.Extensions = append(p.Extensions, &ext)
+	}
+
+	return nil
+}
diff --git a/internal/encoding/ssh/filexfer/open_packets.go b/internal/encoding/ssh/filexfer/open_packets.go
new file mode 100644
index 0000000..b0e25c2
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/open_packets.go
@@ -0,0 +1,79 @@
+package filexfer
+
+// SSH_FXF_* flags.
+const (
+	FlagRead      = 1 << iota // SSH_FXF_READ
+	FlagWrite                 // SSH_FXF_WRITE
+	FlagAppend                // SSH_FXF_APPEND
+	FlagCreate                // SSH_FXF_CREAT
+	FlagTruncate              // SSH_FXF_TRUNC
+	FlagExclusive             // SSH_FXF_EXCL
+)
+
+// OpenPacket defines the SSH_FXP_OPEN packet.
+type OpenPacket struct {
+	Filename string
+	PFlags   uint32
+	Attrs    Attributes
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *OpenPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// string(filename) + uint32(pflags) + ATTRS(attrs)
+		size := 4 + len(p.Filename) + 4 + p.Attrs.Len()
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeOpen, reqid)
+	buf.AppendString(p.Filename)
+	buf.AppendUint32(p.PFlags)
+
+	p.Attrs.MarshalInto(buf)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *OpenPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Filename, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.PFlags, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	return p.Attrs.UnmarshalFrom(buf)
+}
+
+// OpenDirPacket defines the SSH_FXP_OPENDIR packet.
+type OpenDirPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *OpenDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeOpenDir, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *OpenDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/hardlink.go b/internal/encoding/ssh/filexfer/openssh/hardlink.go
new file mode 100644
index 0000000..7f7d5e0
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/hardlink.go
@@ -0,0 +1,74 @@
+package openssh
+
+import (
+	sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+const extensionHardlink = "hardlink@openssh.com"
+
+// RegisterExtensionHardlink registers the "hardlink@openssh.com" extended packet with the encoding/ssh/filexfer package.
+func RegisterExtensionHardlink() {
+	sshfx.RegisterExtendedPacketType(extensionHardlink, func() sshfx.ExtendedData {
+		return new(HardlinkExtendedPacket)
+	})
+}
+
+// ExtensionHardlink returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket.
+func ExtensionHardlink() *sshfx.ExtensionPair {
+	return &sshfx.ExtensionPair{
+		Name: extensionHardlink,
+		Data: "1",
+	}
+}
+
+// HardlinkExtendedPacket defines the hardlink@openssh.com extend packet.
+type HardlinkExtendedPacket struct {
+	OldPath string
+	NewPath string
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended packet.
+func (ep *HardlinkExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	p := &sshfx.ExtendedPacket{
+		ExtendedRequest: extensionHardlink,
+
+		Data: ep,
+	}
+	return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data.
+func (ep *HardlinkExtendedPacket) MarshalInto(buf *sshfx.Buffer) {
+	buf.AppendString(ep.OldPath)
+	buf.AppendString(ep.NewPath)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet.
+func (ep *HardlinkExtendedPacket) MarshalBinary() ([]byte, error) {
+	// string(oldpath) + string(newpath)
+	size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath)
+
+	buf := sshfx.NewBuffer(make([]byte, size))
+	ep.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf.
+func (ep *HardlinkExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+	if ep.OldPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if ep.NewPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep.
+func (ep *HardlinkExtendedPacket) UnmarshalBinary(data []byte) (err error) {
+	return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/openssh.go b/internal/encoding/ssh/filexfer/openssh/openssh.go
new file mode 100644
index 0000000..f93ff17
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/openssh.go
@@ -0,0 +1,2 @@
+// Package openssh implements the openssh secsh-filexfer extensions as described in https://github.com/openssh/openssh-portable/blob/master/PROTOCOL
+package openssh
diff --git a/internal/encoding/ssh/filexfer/openssh/posix-rename.go b/internal/encoding/ssh/filexfer/openssh/posix-rename.go
new file mode 100644
index 0000000..d19a580
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/posix-rename.go
@@ -0,0 +1,74 @@
+package openssh
+
+import (
+	sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+const extensionPosixRename = "posix-rename@openssh.com"
+
+// RegisterExtensionPosixRename registers the "posix-rename@openssh.com" extended packet with the encoding/ssh/filexfer package.
+func RegisterExtensionPosixRename() {
+	sshfx.RegisterExtendedPacketType(extensionPosixRename, func() sshfx.ExtendedData {
+		return new(PosixRenameExtendedPacket)
+	})
+}
+
+// ExtensionPosixRename returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket.
+func ExtensionPosixRename() *sshfx.ExtensionPair {
+	return &sshfx.ExtensionPair{
+		Name: extensionPosixRename,
+		Data: "1",
+	}
+}
+
+// PosixRenameExtendedPacket defines the posix-rename@openssh.com extend packet.
+type PosixRenameExtendedPacket struct {
+	OldPath string
+	NewPath string
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended packet.
+func (ep *PosixRenameExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	p := &sshfx.ExtendedPacket{
+		ExtendedRequest: extensionPosixRename,
+
+		Data: ep,
+	}
+	return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data.
+func (ep *PosixRenameExtendedPacket) MarshalInto(buf *sshfx.Buffer) {
+	buf.AppendString(ep.OldPath)
+	buf.AppendString(ep.NewPath)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet.
+func (ep *PosixRenameExtendedPacket) MarshalBinary() ([]byte, error) {
+	// string(oldpath) + string(newpath)
+	size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath)
+
+	buf := sshfx.NewBuffer(make([]byte, size))
+	ep.MarshalInto(buf)
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf.
+func (ep *PosixRenameExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+	if ep.OldPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if ep.NewPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep.
+func (ep *PosixRenameExtendedPacket) UnmarshalBinary(data []byte) (err error) {
+	return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/statvfs.go b/internal/encoding/ssh/filexfer/openssh/statvfs.go
new file mode 100644
index 0000000..a58fc6a
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/statvfs.go
@@ -0,0 +1,233 @@
+package openssh
+
+import (
+	sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+const extensionStatVFS = "statvfs@openssh.com"
+
+// RegisterExtensionStatVFS registers the "statvfs@openssh.com" extended packet with the encoding/ssh/filexfer package.
+func RegisterExtensionStatVFS() {
+	sshfx.RegisterExtendedPacketType(extensionStatVFS, func() sshfx.ExtendedData {
+		return new(StatVFSExtendedPacket)
+	})
+}
+
+// ExtensionStatVFS returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket.
+func ExtensionStatVFS() *sshfx.ExtensionPair {
+	return &sshfx.ExtensionPair{
+		Name: extensionStatVFS,
+		Data: "2",
+	}
+}
+
+// StatVFSExtendedPacket defines the statvfs@openssh.com extend packet.
+type StatVFSExtendedPacket struct {
+	Path string
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended packet.
+func (ep *StatVFSExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	p := &sshfx.ExtendedPacket{
+		ExtendedRequest: extensionStatVFS,
+
+		Data: ep,
+	}
+	return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data.
+func (ep *StatVFSExtendedPacket) MarshalInto(buf *sshfx.Buffer) {
+	buf.AppendString(ep.Path)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet.
+func (ep *StatVFSExtendedPacket) MarshalBinary() ([]byte, error) {
+	size := 4 + len(ep.Path) // string(path)
+
+	buf := sshfx.NewBuffer(make([]byte, size))
+
+	ep.MarshalInto(buf)
+
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the statvfs@openssh.com extended packet-specific data into ep.
+func (ep *StatVFSExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+	if ep.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the statvfs@openssh.com extended packet-specific data into ep.
+func (ep *StatVFSExtendedPacket) UnmarshalBinary(data []byte) (err error) {
+	return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
+
+const extensionFStatVFS = "fstatvfs@openssh.com"
+
+// RegisterExtensionFStatVFS registers the "fstatvfs@openssh.com" extended packet with the encoding/ssh/filexfer package.
+func RegisterExtensionFStatVFS() {
+	sshfx.RegisterExtendedPacketType(extensionFStatVFS, func() sshfx.ExtendedData {
+		return new(FStatVFSExtendedPacket)
+	})
+}
+
+// ExtensionFStatVFS returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket.
+func ExtensionFStatVFS() *sshfx.ExtensionPair {
+	return &sshfx.ExtensionPair{
+		Name: extensionFStatVFS,
+		Data: "2",
+	}
+}
+
+// FStatVFSExtendedPacket defines the fstatvfs@openssh.com extend packet.
+type FStatVFSExtendedPacket struct {
+	Path string
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended packet.
+func (ep *FStatVFSExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	p := &sshfx.ExtendedPacket{
+		ExtendedRequest: extensionFStatVFS,
+
+		Data: ep,
+	}
+	return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data.
+func (ep *FStatVFSExtendedPacket) MarshalInto(buf *sshfx.Buffer) {
+	buf.AppendString(ep.Path)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet.
+func (ep *FStatVFSExtendedPacket) MarshalBinary() ([]byte, error) {
+	size := 4 + len(ep.Path) // string(path)
+
+	buf := sshfx.NewBuffer(make([]byte, size))
+
+	ep.MarshalInto(buf)
+
+	return buf.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the statvfs@openssh.com extended packet-specific data into ep.
+func (ep *FStatVFSExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+	if ep.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the statvfs@openssh.com extended packet-specific data into ep.
+func (ep *FStatVFSExtendedPacket) UnmarshalBinary(data []byte) (err error) {
+	return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
+
+// The values for the MountFlags field.
+// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL
+const (
+	MountFlagsReadOnly = 0x1 // SSH_FXE_STATVFS_ST_RDONLY
+	MountFlagsNoSUID   = 0x2 // SSH_FXE_STATVFS_ST_NOSUID
+)
+
+// StatVFSExtendedReplyPacket defines the extended reply packet for statvfs@openssh.com and fstatvfs@openssh.com requests.
+type StatVFSExtendedReplyPacket struct {
+	BlockSize     uint64 /* f_bsize:   file system block size */
+	FragmentSize  uint64 /* f_frsize:  fundamental fs block size / fagment size */
+	Blocks        uint64 /* f_blocks:  number of blocks (unit f_frsize) */
+	BlocksFree    uint64 /* f_bfree:   free blocks in filesystem */
+	BlocksAvail   uint64 /* f_bavail:  free blocks for non-root */
+	Files         uint64 /* f_files:   total file inodes */
+	FilesFree     uint64 /* f_ffree:   free file inodes */
+	FilesAvail    uint64 /* f_favail:  free file inodes for to non-root */
+	FilesystemID  uint64 /* f_fsid:    file system id */
+	MountFlags    uint64 /* f_flag:    bit mask of mount flag values */
+	MaxNameLength uint64 /* f_namemax: maximum filename length */
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended reply packet.
+func (ep *StatVFSExtendedReplyPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	p := &sshfx.ExtendedReplyPacket{
+		Data: ep,
+	}
+	return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the (f)statvfs@openssh.com extended reply packet-specific data.
+func (ep *StatVFSExtendedReplyPacket) MarshalInto(buf *sshfx.Buffer) {
+	buf.AppendUint64(ep.BlockSize)
+	buf.AppendUint64(ep.FragmentSize)
+	buf.AppendUint64(ep.Blocks)
+	buf.AppendUint64(ep.BlocksFree)
+	buf.AppendUint64(ep.BlocksAvail)
+	buf.AppendUint64(ep.Files)
+	buf.AppendUint64(ep.FilesFree)
+	buf.AppendUint64(ep.FilesAvail)
+	buf.AppendUint64(ep.FilesystemID)
+	buf.AppendUint64(ep.MountFlags)
+	buf.AppendUint64(ep.MaxNameLength)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the (f)statvfs@openssh.com extended reply packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended reply packet.
+func (ep *StatVFSExtendedReplyPacket) MarshalBinary() ([]byte, error) {
+	size := 11 * 8 // 11 × uint64(various)
+
+	b := sshfx.NewBuffer(make([]byte, size))
+	ep.MarshalInto(b)
+	return b.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the fstatvfs@openssh.com extended reply packet-specific data into ep.
+func (ep *StatVFSExtendedReplyPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+	if ep.BlockSize, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.FragmentSize, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.Blocks, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.BlocksFree, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.BlocksAvail, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.Files, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.FilesFree, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.FilesAvail, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.FilesystemID, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.MountFlags, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+	if ep.MaxNameLength, err = buf.ConsumeUint64(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// UnmarshalBinary decodes the fstatvfs@openssh.com extended reply packet-specific data into ep.
+func (ep *StatVFSExtendedReplyPacket) UnmarshalBinary(data []byte) (err error) {
+	return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/packets.go b/internal/encoding/ssh/filexfer/packets.go
new file mode 100644
index 0000000..d597f49
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/packets.go
@@ -0,0 +1,313 @@
+package filexfer
+
+import (
+	"errors"
+	"fmt"
+	"io"
+)
+
+// smallBufferSize is an initial allocation minimal capacity.
+const smallBufferSize = 64
+
+func newPacketFromType(typ PacketType) (Packet, error) {
+	switch typ {
+	case PacketTypeOpen:
+		return new(OpenPacket), nil
+	case PacketTypeClose:
+		return new(ClosePacket), nil
+	case PacketTypeRead:
+		return new(ReadPacket), nil
+	case PacketTypeWrite:
+		return new(WritePacket), nil
+	case PacketTypeLStat:
+		return new(LStatPacket), nil
+	case PacketTypeFStat:
+		return new(FStatPacket), nil
+	case PacketTypeSetstat:
+		return new(SetstatPacket), nil
+	case PacketTypeFSetstat:
+		return new(FSetstatPacket), nil
+	case PacketTypeOpenDir:
+		return new(OpenDirPacket), nil
+	case PacketTypeReadDir:
+		return new(ReadDirPacket), nil
+	case PacketTypeRemove:
+		return new(RemovePacket), nil
+	case PacketTypeMkdir:
+		return new(MkdirPacket), nil
+	case PacketTypeRmdir:
+		return new(RmdirPacket), nil
+	case PacketTypeRealPath:
+		return new(RealPathPacket), nil
+	case PacketTypeStat:
+		return new(StatPacket), nil
+	case PacketTypeRename:
+		return new(RenamePacket), nil
+	case PacketTypeReadLink:
+		return new(ReadLinkPacket), nil
+	case PacketTypeSymlink:
+		return new(SymlinkPacket), nil
+	case PacketTypeExtended:
+		return new(ExtendedPacket), nil
+	default:
+		return nil, fmt.Errorf("unexpected request packet type: %v", typ)
+	}
+}
+
+// RawPacket implements the general packet format from draft-ietf-secsh-filexfer-02
+//
+// RawPacket is intended for use in clients receiving responses,
+// where a response will be expected to be of a limited number of types,
+// and unmarshaling unknown/unexpected response packets is unnecessary.
+//
+// For servers expecting to receive arbitrary request packet types,
+// use RequestPacket.
+//
+// Defined in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3
+type RawPacket struct {
+	Type      PacketType
+	RequestID uint32
+
+	Data Buffer
+}
+
+// Reset clears the pointers and reference-semantic variables of RawPacket,
+// releasing underlying resources, and making them and the RawPacket suitable to be reused,
+// so long as no other references have been kept.
+func (p *RawPacket) Reset() {
+	p.Data = Buffer{}
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+//
+// The internal p.RequestID is overridden by the reqid argument.
+func (p *RawPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		buf = NewMarshalBuffer(0)
+	}
+
+	buf.StartPacket(p.Type, reqid)
+
+	return buf.Packet(p.Data.Bytes())
+}
+
+// MarshalBinary returns p as the binary encoding of p.
+//
+// This is a convenience implementation primarily intended for tests,
+// because it is inefficient with allocations.
+func (p *RawPacket) MarshalBinary() ([]byte, error) {
+	return ComposePacket(p.MarshalPacket(p.RequestID, nil))
+}
+
+// UnmarshalFrom decodes a RawPacket from the given Buffer into p.
+//
+// The Data field will alias the passed in Buffer,
+// so the buffer passed in should not be reused before RawPacket.Reset().
+func (p *RawPacket) UnmarshalFrom(buf *Buffer) error {
+	typ, err := buf.ConsumeUint8()
+	if err != nil {
+		return err
+	}
+
+	p.Type = PacketType(typ)
+
+	if p.RequestID, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	p.Data = *buf
+	return nil
+}
+
+// UnmarshalBinary decodes a full raw packet out of the given data.
+// It is assumed that the uint32(length) has already been consumed to receive the data.
+//
+// This is a convenience implementation primarily intended for tests,
+// because this must clone the given data byte slice,
+// as Data is not allowed to alias any part of the data byte slice.
+func (p *RawPacket) UnmarshalBinary(data []byte) error {
+	clone := make([]byte, len(data))
+	n := copy(clone, data)
+	return p.UnmarshalFrom(NewBuffer(clone[:n]))
+}
+
+// readPacket reads a uint32 length-prefixed binary data packet from r.
+// using the given byte slice as a backing array.
+//
+// If the packet length read from r is bigger than maxPacketLength,
+// or greater than math.MaxInt32 on a 32-bit implementation,
+// then a `ErrLongPacket` error will be returned.
+//
+// If the given byte slice is insufficient to hold the packet,
+// then it will be extended to fill the packet size.
+func readPacket(r io.Reader, b []byte, maxPacketLength uint32) ([]byte, error) {
+	if cap(b) < 4 {
+		// We will need allocate our own buffer just for reading the packet length.
+
+		// However, we don’t really want to allocate an extremely narrow buffer (4-bytes),
+		// and cause unnecessary allocation churn from both length reads and small packet reads,
+		// so we use smallBufferSize from the bytes package as a reasonable guess.
+
+		// But if callers really do want to force narrow throw-away allocation of every packet body,
+		// they can do so with a buffer of capacity 4.
+		b = make([]byte, smallBufferSize)
+	}
+
+	if _, err := io.ReadFull(r, b[:4]); err != nil {
+		return nil, err
+	}
+
+	length := unmarshalUint32(b)
+	if int(length) < 5 {
+		// Must have at least uint8(type) and uint32(request-id)
+
+		if int(length) < 0 {
+			// Only possible when strconv.IntSize == 32,
+			// the packet length is longer than math.MaxInt32,
+			// and thus longer than any possible slice.
+			return nil, ErrLongPacket
+		}
+
+		return nil, ErrShortPacket
+	}
+	if length > maxPacketLength {
+		return nil, ErrLongPacket
+	}
+
+	if int(length) > cap(b) {
+		// We know int(length) must be positive, because of tests above.
+		b = make([]byte, length)
+	}
+
+	n, err := io.ReadFull(r, b[:length])
+	return b[:n], err
+}
+
+// ReadFrom provides a simple functional packet reader,
+// using the given byte slice as a backing array.
+//
+// To protect against potential denial of service attacks,
+// if the read packet length is longer than maxPacketLength,
+// then no packet data will be read, and ErrLongPacket will be returned.
+// (On 32-bit int architectures, all packets >= 2^31 in length
+// will return ErrLongPacket regardless of maxPacketLength.)
+//
+// If the read packet length is longer than cap(b),
+// then a throw-away slice will allocated to meet the exact packet length.
+// This can be used to limit the length of reused buffers,
+// while still allowing reception of occassional large packets.
+//
+// The Data field may alias the passed in byte slice,
+// so the byte slice passed in should not be reused before RawPacket.Reset().
+func (p *RawPacket) ReadFrom(r io.Reader, b []byte, maxPacketLength uint32) error {
+	b, err := readPacket(r, b, maxPacketLength)
+	if err != nil {
+		return err
+	}
+
+	return p.UnmarshalFrom(NewBuffer(b))
+}
+
+// RequestPacket implements the general packet format from draft-ietf-secsh-filexfer-02
+// but also automatically decode/encodes valid request packets (2 < type < 100 || type == 200).
+//
+// RequestPacket is intended for use in servers receiving requests,
+// where any arbitrary request may be received, and so decoding them automatically
+// is useful.
+//
+// For clients expecting to receive specific response packet types,
+// where automatic unmarshaling of the packet body does not make sense,
+// use RawPacket.
+//
+// Defined in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3
+type RequestPacket struct {
+	RequestID uint32
+
+	Request Packet
+}
+
+// Reset clears the pointers and reference-semantic variables in RequestPacket,
+// releasing underlying resources, and making them and the RequestPacket suitable to be reused,
+// so long as no other references have been kept.
+func (p *RequestPacket) Reset() {
+	p.Request = nil
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+//
+// The internal p.RequestID is overridden by the reqid argument.
+func (p *RequestPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	if p.Request == nil {
+		return nil, nil, errors.New("empty request packet")
+	}
+
+	return p.Request.MarshalPacket(reqid, b)
+}
+
+// MarshalBinary returns p as the binary encoding of p.
+//
+// This is a convenience implementation primarily intended for tests,
+// because it is inefficient with allocations.
+func (p *RequestPacket) MarshalBinary() ([]byte, error) {
+	return ComposePacket(p.MarshalPacket(p.RequestID, nil))
+}
+
+// UnmarshalFrom decodes a RequestPacket from the given Buffer into p.
+//
+// The Request field may alias the passed in Buffer, (e.g. SSH_FXP_WRITE),
+// so the buffer passed in should not be reused before RequestPacket.Reset().
+func (p *RequestPacket) UnmarshalFrom(buf *Buffer) error {
+	typ, err := buf.ConsumeUint8()
+	if err != nil {
+		return err
+	}
+
+	p.Request, err = newPacketFromType(PacketType(typ))
+	if err != nil {
+		return err
+	}
+
+	if p.RequestID, err = buf.ConsumeUint32(); err != nil {
+		return err
+	}
+
+	return p.Request.UnmarshalPacketBody(buf)
+}
+
+// UnmarshalBinary decodes a full request packet out of the given data.
+// It is assumed that the uint32(length) has already been consumed to receive the data.
+//
+// This is a convenience implementation primarily intended for tests,
+// because this must clone the given data byte slice,
+// as Request is not allowed to alias any part of the data byte slice.
+func (p *RequestPacket) UnmarshalBinary(data []byte) error {
+	clone := make([]byte, len(data))
+	n := copy(clone, data)
+	return p.UnmarshalFrom(NewBuffer(clone[:n]))
+}
+
+// ReadFrom provides a simple functional packet reader,
+// using the given byte slice as a backing array.
+//
+// To protect against potential denial of service attacks,
+// if the read packet length is longer than maxPacketLength,
+// then no packet data will be read, and ErrLongPacket will be returned.
+// (On 32-bit int architectures, all packets >= 2^31 in length
+// will return ErrLongPacket regardless of maxPacketLength.)
+//
+// If the read packet length is longer than cap(b),
+// then a throw-away slice will allocated to meet the exact packet length.
+// This can be used to limit the length of reused buffers,
+// while still allowing reception of occassional large packets.
+//
+// The Request field may alias the passed in byte slice,
+// so the byte slice passed in should not be reused before RawPacket.Reset().
+func (p *RequestPacket) ReadFrom(r io.Reader, b []byte, maxPacketLength uint32) error {
+	b, err := readPacket(r, b, maxPacketLength)
+	if err != nil {
+		return err
+	}
+
+	return p.UnmarshalFrom(NewBuffer(b))
+}
diff --git a/internal/encoding/ssh/filexfer/path_packets.go b/internal/encoding/ssh/filexfer/path_packets.go
new file mode 100644
index 0000000..f7085f3
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/path_packets.go
@@ -0,0 +1,318 @@
+package filexfer
+
+// LStatPacket defines the SSH_FXP_LSTAT packet.
+type LStatPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *LStatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeLStat, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *LStatPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// SetstatPacket defines the SSH_FXP_SETSTAT packet.
+type SetstatPacket struct {
+	Path  string
+	Attrs Attributes
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *SetstatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) + p.Attrs.Len() // string(path) + ATTRS(attrs)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeSetstat, reqid)
+	buf.AppendString(p.Path)
+
+	p.Attrs.MarshalInto(buf)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *SetstatPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return p.Attrs.UnmarshalFrom(buf)
+}
+
+// RemovePacket defines the SSH_FXP_REMOVE packet.
+type RemovePacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *RemovePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeRemove, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *RemovePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// MkdirPacket defines the SSH_FXP_MKDIR packet.
+type MkdirPacket struct {
+	Path  string
+	Attrs Attributes
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *MkdirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) + p.Attrs.Len() // string(path) + ATTRS(attrs)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeMkdir, reqid)
+	buf.AppendString(p.Path)
+
+	p.Attrs.MarshalInto(buf)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *MkdirPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return p.Attrs.UnmarshalFrom(buf)
+}
+
+// RmdirPacket defines the SSH_FXP_RMDIR packet.
+type RmdirPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *RmdirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeRmdir, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *RmdirPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// RealPathPacket defines the SSH_FXP_REALPATH packet.
+type RealPathPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *RealPathPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeRealPath, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *RealPathPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// StatPacket defines the SSH_FXP_STAT packet.
+type StatPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *StatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeStat, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *StatPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// RenamePacket defines the SSH_FXP_RENAME packet.
+type RenamePacket struct {
+	OldPath string
+	NewPath string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *RenamePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// string(oldpath) + string(newpath)
+		size := 4 + len(p.OldPath) + 4 + len(p.NewPath)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeRename, reqid)
+	buf.AppendString(p.OldPath)
+	buf.AppendString(p.NewPath)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *RenamePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.OldPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.NewPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// ReadLinkPacket defines the SSH_FXP_READLINK packet.
+type ReadLinkPacket struct {
+	Path string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *ReadLinkPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Path) // string(path)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeReadLink, reqid)
+	buf.AppendString(p.Path)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *ReadLinkPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Path, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// SymlinkPacket defines the SSH_FXP_SYMLINK packet.
+//
+// The order of the arguments to the SSH_FXP_SYMLINK method was inadvertently reversed.
+// Unfortunately, the reversal was not noticed until the server was widely deployed.
+// Covered in Section 3.1 of https://github.com/openssh/openssh-portable/blob/master/PROTOCOL
+type SymlinkPacket struct {
+	LinkPath   string
+	TargetPath string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *SymlinkPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// string(targetpath) + string(linkpath)
+		size := 4 + len(p.TargetPath) + 4 + len(p.LinkPath)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeSymlink, reqid)
+
+	// Arguments were inadvertently reversed.
+	buf.AppendString(p.TargetPath)
+	buf.AppendString(p.LinkPath)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *SymlinkPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	// Arguments were inadvertently reversed.
+	if p.TargetPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.LinkPath, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
diff --git a/internal/encoding/ssh/filexfer/permissions.go b/internal/encoding/ssh/filexfer/permissions.go
new file mode 100644
index 0000000..c5050ba
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/permissions.go
@@ -0,0 +1,90 @@
+package filexfer
+
+// FileMode represents a file’s mode and permission bits.
+// The bits are defined according to POSIX standards,
+// and may not apply to the OS being built for.
+type FileMode uint32
+
+// Permission flags, defined here to avoid potential inconsistencies in individual OS implementations.
+const (
+	ModePerm       FileMode = 0o0777 // S_IRWXU | S_IRWXG | S_IRWXO
+	ModeUserRead   FileMode = 0o0400 // S_IRUSR
+	ModeUserWrite  FileMode = 0o0200 // S_IWUSR
+	ModeUserExec   FileMode = 0o0100 // S_IXUSR
+	ModeGroupRead  FileMode = 0o0040 // S_IRGRP
+	ModeGroupWrite FileMode = 0o0020 // S_IWGRP
+	ModeGroupExec  FileMode = 0o0010 // S_IXGRP
+	ModeOtherRead  FileMode = 0o0004 // S_IROTH
+	ModeOtherWrite FileMode = 0o0002 // S_IWOTH
+	ModeOtherExec  FileMode = 0o0001 // S_IXOTH
+
+	ModeSetUID FileMode = 0o4000 // S_ISUID
+	ModeSetGID FileMode = 0o2000 // S_ISGID
+	ModeSticky FileMode = 0o1000 // S_ISVTX
+
+	ModeType       FileMode = 0xF000 // S_IFMT
+	ModeNamedPipe  FileMode = 0x1000 // S_IFIFO
+	ModeCharDevice FileMode = 0x2000 // S_IFCHR
+	ModeDir        FileMode = 0x4000 // S_IFDIR
+	ModeDevice     FileMode = 0x6000 // S_IFBLK
+	ModeRegular    FileMode = 0x8000 // S_IFREG
+	ModeSymlink    FileMode = 0xA000 // S_IFLNK
+	ModeSocket     FileMode = 0xC000 // S_IFSOCK
+)
+
+// IsDir reports whether m describes a directory.
+// That is, it tests for m.Type() == ModeDir.
+func (m FileMode) IsDir() bool {
+	return (m & ModeType) == ModeDir
+}
+
+// IsRegular reports whether m describes a regular file.
+// That is, it tests for m.Type() == ModeRegular
+func (m FileMode) IsRegular() bool {
+	return (m & ModeType) == ModeRegular
+}
+
+// Perm returns the POSIX permission bits in m (m & ModePerm).
+func (m FileMode) Perm() FileMode {
+	return (m & ModePerm)
+}
+
+// Type returns the type bits in m (m & ModeType).
+func (m FileMode) Type() FileMode {
+	return (m & ModeType)
+}
+
+// String returns a `-rwxrwxrwx` style string representing the `ls -l` POSIX permissions string.
+func (m FileMode) String() string {
+	var buf [10]byte
+
+	switch m.Type() {
+	case ModeRegular:
+		buf[0] = '-'
+	case ModeDir:
+		buf[0] = 'd'
+	case ModeSymlink:
+		buf[0] = 'l'
+	case ModeDevice:
+		buf[0] = 'b'
+	case ModeCharDevice:
+		buf[0] = 'c'
+	case ModeNamedPipe:
+		buf[0] = 'p'
+	case ModeSocket:
+		buf[0] = 's'
+	default:
+		buf[0] = '?'
+	}
+
+	const rwx = "rwxrwxrwx"
+	for i, c := range rwx {
+		if m&(1<<uint(9-1-i)) != 0 {
+			buf[i+1] = byte(c)
+		} else {
+			buf[i+1] = '-'
+		}
+	}
+
+	return string(buf[:])
+}
diff --git a/internal/encoding/ssh/filexfer/response_packets.go b/internal/encoding/ssh/filexfer/response_packets.go
new file mode 100644
index 0000000..831b443
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/response_packets.go
@@ -0,0 +1,180 @@
+package filexfer
+
+// StatusPacket defines the SSH_FXP_STATUS packet.
+//
+// Specified in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-7
+type StatusPacket struct {
+	StatusCode   Status
+	ErrorMessage string
+	LanguageTag  string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *StatusPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		// uint32(error/status code) + string(error message) + string(language tag)
+		size := 4 + 4 + len(p.ErrorMessage) + 4 + len(p.LanguageTag)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeStatus, reqid)
+	buf.AppendUint32(uint32(p.StatusCode))
+	buf.AppendString(p.ErrorMessage)
+	buf.AppendString(p.LanguageTag)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *StatusPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	statusCode, err := buf.ConsumeUint32()
+	if err != nil {
+		return err
+	}
+	p.StatusCode = Status(statusCode)
+
+	if p.ErrorMessage, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	if p.LanguageTag, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// HandlePacket defines the SSH_FXP_HANDLE packet.
+type HandlePacket struct {
+	Handle string
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *HandlePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 + len(p.Handle) // string(handle)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeHandle, reqid)
+	buf.AppendString(p.Handle)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *HandlePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Handle, err = buf.ConsumeString(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// DataPacket defines the SSH_FXP_DATA packet.
+type DataPacket struct {
+	Data []byte
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *DataPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 // uint32(len(data)); data content in payload
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeData, reqid)
+	buf.AppendUint32(uint32(len(p.Data)))
+
+	return buf.Packet(p.Data)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *DataPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	if p.Data, err = buf.ConsumeByteSlice(); err != nil {
+		return err
+	}
+
+	return nil
+}
+
+// NamePacket defines the SSH_FXP_NAME packet.
+type NamePacket struct {
+	Entries []*NameEntry
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *NamePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := 4 // uint32(len(entries))
+
+		for _, e := range p.Entries {
+			size += e.Len()
+		}
+
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeName, reqid)
+	buf.AppendUint32(uint32(len(p.Entries)))
+
+	for _, e := range p.Entries {
+		e.MarshalInto(buf)
+	}
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *NamePacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	count, err := buf.ConsumeUint32()
+	if err != nil {
+		return err
+	}
+
+	p.Entries = make([]*NameEntry, 0, count)
+
+	for i := uint32(0); i < count; i++ {
+		var e NameEntry
+		if err := e.UnmarshalFrom(buf); err != nil {
+			return err
+		}
+
+		p.Entries = append(p.Entries, &e)
+	}
+
+	return nil
+}
+
+// AttrsPacket defines the SSH_FXP_ATTRS packet.
+type AttrsPacket struct {
+	Attrs Attributes
+}
+
+// MarshalPacket returns p as a two-part binary encoding of p.
+func (p *AttrsPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+	buf := NewBuffer(b)
+	if buf.Cap() < 9 {
+		size := p.Attrs.Len() // ATTRS(attrs)
+		buf = NewMarshalBuffer(size)
+	}
+
+	buf.StartPacket(PacketTypeAttrs, reqid)
+	p.Attrs.MarshalInto(buf)
+
+	return buf.Packet(payload)
+}
+
+// UnmarshalPacketBody unmarshals the packet body from the given Buffer.
+// It is assumed that the uint32(request-id) has already been consumed.
+func (p *AttrsPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+	return p.Attrs.UnmarshalFrom(buf)
+}
diff --git a/packet.go b/packet.go
index faf5ffd..4a68635 100644
--- a/packet.go
+++ b/packet.go
@@ -133,7 +133,7 @@ func marshalPacket(m encoding.BinaryMarshaler) (header, payload []byte, err erro
 func sendPacket(w io.Writer, m encoding.BinaryMarshaler) error {
 	header, payload, err := marshalPacket(m)
 	if err != nil {
-		return errors.Wrap(err, "binary marshaller failed")
+		return errors.Errorf("binary marshaller failed: %v", err)
 	}
 
 	length := len(header) + len(payload) - 4 // subtract the uint32(length) from the start
@@ -146,12 +146,12 @@ func sendPacket(w io.Writer, m encoding.BinaryMarshaler) error {
 	binary.BigEndian.PutUint32(header[:4], uint32(length))
 
 	if _, err := w.Write(header); err != nil {
-		return errors.Wrap(err, "failed to send packet")
+		return errors.Errorf("failed to send packet: %v", err)
 	}
 
 	if len(payload) > 0 {
 		if _, err := w.Write(payload); err != nil {
-			return errors.Wrap(err, "failed to send packet payload")
+			return errors.Errorf("failed to send packet payload: %v", err)
 		}
 	}
 
diff --git a/request-errors.go b/request-errors.go
index 6505b5c..e4b4b86 100644
--- a/request-errors.go
+++ b/request-errors.go
@@ -37,18 +37,18 @@ func (e fxerr) Error() string {
 	case ErrSSHFxEOF:
 		return "EOF"
 	case ErrSSHFxNoSuchFile:
-		return "no such file"
+		return "No Such File"
 	case ErrSSHFxPermissionDenied:
-		return "permission denied"
+		return "Permission Denied"
 	case ErrSSHFxBadMessage:
-		return "bad message"
+		return "Bad Message"
 	case ErrSSHFxNoConnection:
-		return "no connection"
+		return "No Connection"
 	case ErrSSHFxConnectionLost:
-		return "connection lost"
+		return "Connection Lost"
 	case ErrSSHFxOpUnsupported:
-		return "operation unsupported"
+		return "Operation Unsupported"
 	default:
-		return "failure"
+		return "Failure"
 	}
 }
diff --git a/sftp.go b/sftp.go
index 51d0386..912dff1 100644
--- a/sftp.go
+++ b/sftp.go
@@ -58,9 +58,9 @@ const (
 	sshFxNoMedia                 = 13
 	sshFxNoSpaceOnFilesystem     = 14
 	sshFxQuotaExceeded           = 15
-	sshFxUnknownPrincipal        = 16
+	sshFxUnlnownPrincipal        = 16
 	sshFxLockConflict            = 17
-	sshFxDirNotEmpty             = 18
+	sshFxDitNotEmpty             = 18
 	sshFxNotADirectory           = 19
 	sshFxInvalidFilename         = 20
 	sshFxLinkLoop                = 21
@@ -200,15 +200,15 @@ func unimplementedPacketErr(u uint8) error {
 type unexpectedIDErr struct{ want, got uint32 }
 
 func (u *unexpectedIDErr) Error() string {
-	return fmt.Sprintf("sftp: unexpected id: want %d, got %d", u.want, u.got)
+	return fmt.Sprintf("sftp: unexpected id: want %v, got %v", u.want, u.got)
 }
 
 func unimplementedSeekWhence(whence int) error {
-	return errors.Errorf("sftp: unimplemented seek whence %d", whence)
+	return errors.Errorf("sftp: unimplemented seek whence %v", whence)
 }
 
 func unexpectedCount(want, got uint32) error {
-	return errors.Errorf("sftp: unexpected count: want %d, got %d", want, got)
+	return errors.Errorf("sftp: unexpected count: want %v, got %v", want, got)
 }
 
 type unexpectedVersionErr struct{ want, got uint32 }
@@ -239,7 +239,7 @@ func getSupportedExtensionByName(extensionName string) (sshExtensionPair, error)
 			return supportedExtension, nil
 		}
 	}
-	return sshExtensionPair{}, errors.Errorf("unsupported extension: %s", extensionName)
+	return sshExtensionPair{}, fmt.Errorf("Unsupported extension: %v", extensionName)
 }
 
 // SetSFTPExtensions allows to customize the supported server extensions.


Test patch:
diff --git a/client_integration_test.go b/client_integration_test.go
index 59fdb83..46885e1 100644
--- a/client_integration_test.go
+++ b/client_integration_test.go
@@ -6,6 +6,7 @@ package sftp
 import (
 	"bytes"
 	"crypto/sha1"
+	"encoding"
 	"errors"
 	"io"
 	"io/ioutil"
@@ -1367,7 +1368,8 @@ func TestClientRead(t *testing.T) {
 // and the hash of the contents.
 func readHash(t *testing.T, r io.Reader) (string, int64) {
 	h := sha1.New()
-	read, err := io.Copy(h, r)
+	tr := io.TeeReader(r, h)
+	read, err := io.Copy(ioutil.Discard, tr)
 	if err != nil {
 		t.Fatal(err)
 	}
@@ -1497,48 +1499,26 @@ func TestClientReadFrom(t *testing.T) {
 var errFakeNet = errors.New("Fake network issue")
 
 func TestClientReadFromDeadlock(t *testing.T) {
-	for i := 0; i < 5; i++ {
-		clientWriteDeadlock(t, i, func(f *File) {
-			b := make([]byte, 32768*4)
-			content := bytes.NewReader(b)
-			_, err := f.ReadFrom(content)
-			if !errors.Is(err, errFakeNet) {
-				t.Fatal("Didn't recieve correct error:", err)
-			}
-		})
-	}
+	clientWriteDeadlock(t, 1, func(f *File) {
+		b := make([]byte, 32768*4)
+		content := bytes.NewReader(b)
+		_, err := f.ReadFrom(content)
+		if err != errFakeNet {
+			t.Fatal("Didn't recieve correct error:", err)
+		}
+	})
 }
 
 // Write has exact same problem
 func TestClientWriteDeadlock(t *testing.T) {
-	for i := 0; i < 5; i++ {
-		clientWriteDeadlock(t, i, func(f *File) {
-			b := make([]byte, 32768*4)
+	clientWriteDeadlock(t, 1, func(f *File) {
+		b := make([]byte, 32768*4)
 
-			_, err := f.Write(b)
-			if !errors.Is(err, errFakeNet) {
-				t.Fatal("Didn't recieve correct error:", err)
-			}
-		})
-	}
-}
-
-type timeBombWriter struct {
-	count int
-	w     io.WriteCloser
-}
-
-func (w *timeBombWriter) Write(b []byte) (int, error) {
-	if w.count < 1 {
-		return 0, errFakeNet
-	}
-
-	w.count--
-	return w.w.Write(b)
-}
-
-func (w *timeBombWriter) Close() error {
-	return w.w.Close()
+		_, err := f.Write(b)
+		if err != errFakeNet {
+			t.Fatal("Didn't recieve correct error:", err)
+		}
+	})
 }
 
 // shared body for both previous tests
@@ -1563,13 +1543,20 @@ func clientWriteDeadlock(t *testing.T, N int, badfunc func(*File)) {
 	}
 	defer w.Close()
 
-	// Override the clienConn Writer with a failing version
-	// Replicates network error/drop part way through (after N good writes)
-	wrap := sftp.clientConn.conn.WriteCloser
-	sftp.clientConn.conn.WriteCloser = &timeBombWriter{
-		count: N,
-		w:     wrap,
+	// Override sendPacket with failing version
+	// Replicates network error/drop part way through (after 1 good packet)
+	count := 0
+	sendPacketTest := func(w io.Writer, m encoding.BinaryMarshaler) error {
+		count++
+		if count > N {
+			return errFakeNet
+		}
+		return sendPacket(w, m)
 	}
+	sftp.clientConn.conn.sendPacketTest = sendPacketTest
+	defer func() {
+		sftp.clientConn.conn.sendPacketTest = nil
+	}()
 
 	// this locked (before the fix)
 	badfunc(w)
@@ -1577,31 +1564,27 @@ func clientWriteDeadlock(t *testing.T, N int, badfunc func(*File)) {
 
 // Read/WriteTo has this issue as well
 func TestClientReadDeadlock(t *testing.T) {
-	for i := 0; i < 3; i++ {
-		clientReadDeadlock(t, i, func(f *File) {
-			b := make([]byte, 32768*4)
+	clientReadDeadlock(t, 1, func(f *File) {
+		b := make([]byte, 32768*4)
 
-			_, err := f.Read(b)
-			if !errors.Is(err, errFakeNet) {
-				t.Fatal("Didn't recieve correct error:", err)
-			}
-		})
-	}
+		_, err := f.Read(b)
+		if err != errFakeNet {
+			t.Fatal("Didn't recieve correct error:", err)
+		}
+	})
 }
 
 func TestClientWriteToDeadlock(t *testing.T) {
-	for i := 0; i < 3; i++ {
-		clientReadDeadlock(t, i, func(f *File) {
-			b := make([]byte, 32768*4)
+	clientReadDeadlock(t, 2, func(f *File) {
+		b := make([]byte, 32768*4)
 
-			buf := bytes.NewBuffer(b)
+		buf := bytes.NewBuffer(b)
 
-			_, err := f.WriteTo(buf)
-			if !errors.Is(err, errFakeNet) {
-				t.Fatal("Didn't recieve correct error:", err)
-			}
-		})
-	}
+		_, err := f.WriteTo(buf)
+		if err != errFakeNet {
+			t.Fatal("Didn't recieve correct error:", err)
+		}
+	})
 }
 
 func clientReadDeadlock(t *testing.T, N int, badfunc func(*File)) {
@@ -1637,14 +1620,22 @@ func clientReadDeadlock(t *testing.T, N int, badfunc func(*File)) {
 	}
 	defer r.Close()
 
-	// Override the clienConn Writer with a failing version
-	// Replicates network error/drop part way through (after N good writes)
-	wrap := sftp.clientConn.conn.WriteCloser
-	sftp.clientConn.conn.WriteCloser = &timeBombWriter{
-		count: N,
-		w:     wrap,
+	// Override sendPacket with failing version
+	// Replicates network error/drop part way through (after 1 good packet)
+	count := 0
+	sendPacketTest := func(w io.Writer, m encoding.BinaryMarshaler) error {
+		count++
+		if count > N {
+			return errFakeNet
+		}
+		return sendPacket(w, m)
 	}
 
+	sftp.clientConn.conn.sendPacketTest = sendPacketTest
+	defer func() {
+		sftp.clientConn.conn.sendPacketTest = nil
+	}()
+
 	// this locked (before the fix)
 	badfunc(r)
 }
@@ -2481,11 +2472,11 @@ func benchmarkWriteTo(b *testing.B, bufsize int, delay time.Duration) {
 	f.Write(data)
 	f.Close()
 
-	buf := bytes.NewBuffer(make([]byte, 0, size))
-
 	b.ResetTimer()
 	b.SetBytes(int64(size))
 
+	buf := new(bytes.Buffer)
+
 	for i := 0; i < b.N; i++ {
 		buf.Reset()
 
diff --git a/client_test.go b/client_test.go
index 8a8c51b..05b885c 100644
--- a/client_test.go
+++ b/client_test.go
@@ -239,21 +239,3 @@ func TestClientShortPacket(t *testing.T) {
 		t.Fatalf("expected error: %v, got: %v", errShortPacket, err)
 	}
 }
-
-// Issue #418: panic in clientConn.recv when the sid is incomplete.
-func TestClientNoSid(t *testing.T) {
-	stream := new(bytes.Buffer)
-	sendPacket(stream, &sshFxVersionPacket{Version: sftpProtocolVersion})
-	// Next packet has the sid cut short after two bytes.
-	stream.Write([]byte{0, 0, 0, 10, 0, 0})
-
-	c, err := NewClientPipe(stream, &sink{})
-	if err != nil {
-		t.Fatal(err)
-	}
-
-	_, err = c.Stat("anything")
-	if !errors.Is(err, ErrSSHFxConnectionLost) {
-		t.Fatal("expected ErrSSHFxConnectionLost, got", err)
-	}
-}
diff --git a/example_test.go b/example_test.go
index 0093eb0..cf85929 100644
--- a/example_test.go
+++ b/example_test.go
@@ -131,7 +131,7 @@ func ExampleClient_Mkdir_parents() {
 					fi, err = client.Stat(parents)
 					if err == nil {
 						if !fi.IsDir() {
-							return fmt.Errorf("file exists: %s", parents)
+							return fmt.Errorf("File exists: %s", parents)
 						}
 					}
 				}
diff --git a/internal/encoding/ssh/filexfer/attrs_test.go b/internal/encoding/ssh/filexfer/attrs_test.go
new file mode 100644
index 0000000..a4504a4
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/attrs_test.go
@@ -0,0 +1,227 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestAttributes(t *testing.T) {
+	const (
+		size  = 0x123456789ABCDEF0
+		uid   = 1000
+		gid   = 100
+		perms = 0x87654321
+		atime = 0x2A2B2C2D
+		mtime = 0x42434445
+	)
+
+	extAttr := ExtendedAttribute{
+		Type: "foo",
+		Data: "bar",
+	}
+
+	attr := &Attributes{
+		Size:        size,
+		UID:         uid,
+		GID:         gid,
+		Permissions: perms,
+		ATime:       atime,
+		MTime:       mtime,
+		ExtendedAttributes: []ExtendedAttribute{
+			extAttr,
+		},
+	}
+
+	type test struct {
+		name    string
+		flags   uint32
+		encoded []byte
+	}
+
+	tests := []test{
+		{
+			name: "empty",
+			encoded: []byte{
+				0x00, 0x00, 0x00, 0x00,
+			},
+		},
+		{
+			name:  "size",
+			flags: AttrSize,
+			encoded: []byte{
+				0x00, 0x00, 0x00, 0x01,
+				0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
+			},
+		},
+		{
+			name:  "uidgid",
+			flags: AttrUIDGID,
+			encoded: []byte{
+				0x00, 0x00, 0x00, 0x02,
+				0x00, 0x00, 0x03, 0xE8,
+				0x00, 0x00, 0x00, 100,
+			},
+		},
+		{
+			name:  "permissions",
+			flags: AttrPermissions,
+			encoded: []byte{
+				0x00, 0x00, 0x00, 0x04,
+				0x87, 0x65, 0x43, 0x21,
+			},
+		},
+		{
+			name:  "acmodtime",
+			flags: AttrACModTime,
+			encoded: []byte{
+				0x00, 0x00, 0x00, 0x08,
+				0x2A, 0x2B, 0x2C, 0x2D,
+				0x42, 0x43, 0x44, 0x45,
+			},
+		},
+		{
+			name:  "extended",
+			flags: AttrExtended,
+			encoded: []byte{
+				0x80, 0x00, 0x00, 0x00,
+				0x00, 0x00, 0x00, 0x01,
+				0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o',
+				0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r',
+			},
+		},
+		{
+			name:  "size uidgid permisssions acmodtime extended",
+			flags: AttrSize | AttrUIDGID | AttrPermissions | AttrACModTime | AttrExtended,
+			encoded: []byte{
+				0x80, 0x00, 0x00, 0x0F,
+				0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
+				0x00, 0x00, 0x03, 0xE8,
+				0x00, 0x00, 0x00, 100,
+				0x87, 0x65, 0x43, 0x21,
+				0x2A, 0x2B, 0x2C, 0x2D,
+				0x42, 0x43, 0x44, 0x45,
+				0x00, 0x00, 0x00, 0x01,
+				0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o',
+				0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r',
+			},
+		},
+	}
+
+	for _, tt := range tests {
+		attr := *attr
+
+		t.Run(tt.name, func(t *testing.T) {
+			attr.Flags = tt.flags
+
+			buf := new(Buffer)
+			attr.MarshalInto(buf)
+
+			if got, want := buf.Bytes(), tt.encoded; !bytes.Equal(got, want) {
+				t.Fatalf("MarshalInto() = %X, but wanted %X", got, want)
+			}
+
+			attr = Attributes{}
+
+			if err := attr.UnmarshalFrom(buf); err != nil {
+				t.Fatal("unexpected error:", err)
+			}
+
+			if attr.Flags != tt.flags {
+				t.Errorf("UnmarshalFrom(): Flags was %x, but wanted %x", attr.Flags, tt.flags)
+			}
+
+			if attr.Flags&AttrSize != 0 && attr.Size != size {
+				t.Errorf("UnmarshalFrom(): Size was %x, but wanted %x", attr.Size, size)
+			}
+
+			if attr.Flags&AttrUIDGID != 0 {
+				if attr.UID != uid {
+					t.Errorf("UnmarshalFrom(): UID was %x, but wanted %x", attr.UID, uid)
+				}
+
+				if attr.GID != gid {
+					t.Errorf("UnmarshalFrom(): GID was %x, but wanted %x", attr.GID, gid)
+				}
+			}
+
+			if attr.Flags&AttrPermissions != 0 && attr.Permissions != perms {
+				t.Errorf("UnmarshalFrom(): Permissions was %#v, but wanted %#v", attr.Permissions, perms)
+			}
+
+			if attr.Flags&AttrACModTime != 0 {
+				if attr.ATime != atime {
+					t.Errorf("UnmarshalFrom(): ATime was %x, but wanted %x", attr.ATime, atime)
+				}
+
+				if attr.MTime != mtime {
+					t.Errorf("UnmarshalFrom(): MTime was %x, but wanted %x", attr.MTime, mtime)
+				}
+			}
+
+			if attr.Flags&AttrExtended != 0 {
+				extAttrs := attr.ExtendedAttributes
+
+				if count := len(extAttrs); count != 1 {
+					t.Fatalf("UnmarshalFrom(): len(ExtendedAttributes) was %d, but wanted %d", count, 1)
+				}
+
+				if got := extAttrs[0]; got != extAttr {
+					t.Errorf("UnmarshalFrom(): ExtendedAttributes[0] was %#v, but wanted %#v", got, extAttr)
+				}
+			}
+		})
+	}
+}
+
+func TestNameEntry(t *testing.T) {
+	const (
+		filename = "foo"
+		longname = "bar"
+		perms    = 0x87654321
+	)
+
+	e := &NameEntry{
+		Filename: filename,
+		Longname: longname,
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	buf := new(Buffer)
+	e.MarshalInto(buf)
+
+	want := []byte{
+		0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r',
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if got := buf.Bytes(); !bytes.Equal(got, want) {
+		t.Fatalf("MarshalInto() = %X, but wanted %X", got, want)
+	}
+
+	*e = NameEntry{}
+
+	if err := e.UnmarshalFrom(buf); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if e.Filename != filename {
+		t.Errorf("UnmarhsalFrom(): Filename was %q, but expected %q", e.Filename, filename)
+	}
+
+	if e.Longname != longname {
+		t.Errorf("UnmarhsalFrom(): Longname was %q, but expected %q", e.Longname, longname)
+	}
+
+	if e.Attrs.Flags != AttrPermissions {
+		t.Errorf("UnmarshalBinary(): Attrs.Flag was %#x, but expected %#x", e.Attrs.Flags, AttrPermissions)
+	}
+
+	if e.Attrs.Permissions != perms {
+		t.Errorf("UnmarshalBinary(): Attrs.Permissions was %#v, but expected %#v", e.Attrs.Permissions, perms)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/extended_packets_test.go b/internal/encoding/ssh/filexfer/extended_packets_test.go
new file mode 100644
index 0000000..668ef57
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extended_packets_test.go
@@ -0,0 +1,240 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+type testExtendedData struct {
+	value uint8
+}
+
+func (d *testExtendedData) MarshalBinary() ([]byte, error) {
+	buf := NewBuffer(make([]byte, 0, 4))
+
+	buf.AppendUint8(d.value ^ 0x2a)
+
+	return buf.Bytes(), nil
+}
+
+func (d *testExtendedData) UnmarshalBinary(data []byte) error {
+	buf := NewBuffer(data)
+
+	v, err := buf.ConsumeUint8()
+	if err != nil {
+		return err
+	}
+
+	d.value = v ^ 0x2a
+
+	return nil
+}
+
+var _ Packet = &ExtendedPacket{}
+
+func TestExtendedPacketNoData(t *testing.T) {
+	const (
+		id              = 42
+		extendedRequest = "foo@example"
+	)
+
+	p := &ExtendedPacket{
+		ExtendedRequest: extendedRequest,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 20,
+		200,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ExtendedPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.ExtendedRequest != extendedRequest {
+		t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest)
+	}
+}
+
+func TestExtendedPacketTestData(t *testing.T) {
+	const (
+		id              = 42
+		extendedRequest = "foo@example"
+		textValue       = 13
+	)
+
+	const value = 13
+
+	p := &ExtendedPacket{
+		ExtendedRequest: extendedRequest,
+		Data: &testExtendedData{
+			value: textValue,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 21,
+		200,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e',
+		0x27,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ExtendedPacket{
+		Data: new(testExtendedData),
+	}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.ExtendedRequest != extendedRequest {
+		t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest)
+	}
+
+	if data, ok := p.Data.(*testExtendedData); !ok {
+		t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data)
+
+	} else if data.value != value {
+		t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", data.value, value)
+	}
+
+	*p = ExtendedPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.ExtendedRequest != extendedRequest {
+		t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest)
+	}
+
+	wantBuffer := []byte{0x27}
+
+	if data, ok := p.Data.(*Buffer); !ok {
+		t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data)
+
+	} else if !bytes.Equal(data.b, wantBuffer) {
+		t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", data.b, wantBuffer)
+	}
+}
+
+var _ Packet = &ExtendedReplyPacket{}
+
+func TestExtendedReplyNoData(t *testing.T) {
+	const (
+		id = 42
+	)
+
+	p := &ExtendedReplyPacket{}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 5,
+		201,
+		0x00, 0x00, 0x00, 42,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ExtendedReplyPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+}
+
+func TestExtendedReplyPacketTestData(t *testing.T) {
+	const (
+		id        = 42
+		textValue = 13
+	)
+
+	const value = 13
+
+	p := &ExtendedReplyPacket{
+		Data: &testExtendedData{
+			value: textValue,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 6,
+		201,
+		0x00, 0x00, 0x00, 42,
+		0x27,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ExtendedReplyPacket{
+		Data: new(testExtendedData),
+	}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if data, ok := p.Data.(*testExtendedData); !ok {
+		t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data)
+
+	} else if data.value != value {
+		t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", data.value, value)
+	}
+
+	*p = ExtendedReplyPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	wantBuffer := []byte{0x27}
+
+	if data, ok := p.Data.(*Buffer); !ok {
+		t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data)
+
+	} else if !bytes.Equal(data.b, wantBuffer) {
+		t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", data.b, wantBuffer)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/extensions_test.go b/internal/encoding/ssh/filexfer/extensions_test.go
new file mode 100644
index 0000000..c145c03
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extensions_test.go
@@ -0,0 +1,48 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestExtensionPair(t *testing.T) {
+	const (
+		name = "foo"
+		data = "1"
+	)
+
+	pair := &ExtensionPair{
+		Name: name,
+		Data: data,
+	}
+
+	buf := new(Buffer)
+
+	pair.MarshalInto(buf)
+
+	want := []byte{
+		0x00, 0x00, 0x00, 3,
+		'f', 'o', 'o',
+		0x00, 0x00, 0x00, 1,
+		'1',
+	}
+
+	if got := buf.Bytes(); !bytes.Equal(got, want) {
+		t.Errorf("ExtensionPair.MarshalInto() = %X, but wanted %X", got, want)
+	}
+
+	*pair = ExtensionPair{}
+
+	if err := pair.UnmarshalFrom(buf); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if pair.Name != name {
+		t.Errorf("ExtensionPair.UnmarshalFrom(): Name was %q, but expected %q", pair.Name, name)
+	}
+
+	if pair.Data != data {
+		t.Errorf("RawPacket.UnmarshalBinary(): Data was %q, but expected %q", pair.Data, data)
+	}
+
+}
diff --git a/internal/encoding/ssh/filexfer/fx_test.go b/internal/encoding/ssh/filexfer/fx_test.go
new file mode 100644
index 0000000..4d321d9
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fx_test.go
@@ -0,0 +1,84 @@
+package filexfer
+
+import (
+	"bufio"
+	"regexp"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// This string data is copied verbatim from https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13
+var fxStandardsText = `
+SSH_FX_OK                            0
+SSH_FX_EOF                           1
+SSH_FX_NO_SUCH_FILE                  2
+SSH_FX_PERMISSION_DENIED             3
+SSH_FX_FAILURE                       4
+SSH_FX_BAD_MESSAGE                   5
+SSH_FX_NO_CONNECTION                 6
+SSH_FX_CONNECTION_LOST               7
+SSH_FX_OP_UNSUPPORTED                8
+SSH_FX_INVALID_HANDLE                9
+SSH_FX_NO_SUCH_PATH                  10
+SSH_FX_FILE_ALREADY_EXISTS           11
+SSH_FX_WRITE_PROTECT                 12
+SSH_FX_NO_MEDIA                      13
+SSH_FX_NO_SPACE_ON_FILESYSTEM        14
+SSH_FX_QUOTA_EXCEEDED                15
+SSH_FX_UNKNOWN_PRINCIPAL             16
+SSH_FX_LOCK_CONFLICT                 17
+SSH_FX_DIR_NOT_EMPTY                 18
+SSH_FX_NOT_A_DIRECTORY               19
+SSH_FX_INVALID_FILENAME              20
+SSH_FX_LINK_LOOP                     21
+SSH_FX_CANNOT_DELETE                 22
+SSH_FX_INVALID_PARAMETER             23
+SSH_FX_FILE_IS_A_DIRECTORY           24
+SSH_FX_BYTE_RANGE_LOCK_CONFLICT      25
+SSH_FX_BYTE_RANGE_LOCK_REFUSED       26
+SSH_FX_DELETE_PENDING                27
+SSH_FX_FILE_CORRUPT                  28
+SSH_FX_OWNER_INVALID                 29
+SSH_FX_GROUP_INVALID                 30
+SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK   31
+`
+
+func TestFxNames(t *testing.T) {
+	whitespace := regexp.MustCompile(`[[:space:]]+`)
+
+	scan := bufio.NewScanner(strings.NewReader(fxStandardsText))
+
+	for scan.Scan() {
+		line := scan.Text()
+		if i := strings.Index(line, "//"); i >= 0 {
+			line = line[:i]
+		}
+
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+
+		fields := whitespace.Split(line, 2)
+		if len(fields) < 2 {
+			t.Fatalf("unexpected standards text line: %q", line)
+		}
+
+		name, value := fields[0], fields[1]
+		n, err := strconv.Atoi(value)
+		if err != nil {
+			t.Fatal("unexpected error:", err)
+		}
+
+		fx := Status(n)
+
+		if got := fx.String(); got != name {
+			t.Errorf("fx name mismatch for %d: got %q, but want %q", n, got, name)
+		}
+	}
+
+	if err := scan.Err(); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/fxp_test.go b/internal/encoding/ssh/filexfer/fxp_test.go
new file mode 100644
index 0000000..14e8ff7
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fxp_test.go
@@ -0,0 +1,84 @@
+package filexfer
+
+import (
+	"bufio"
+	"regexp"
+	"strconv"
+	"strings"
+	"testing"
+)
+
+// This string data is copied verbatim from https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13
+var fxpStandardsText = `
+SSH_FXP_INIT                1
+SSH_FXP_VERSION             2
+SSH_FXP_OPEN                3
+SSH_FXP_CLOSE               4
+SSH_FXP_READ                5
+SSH_FXP_WRITE               6
+SSH_FXP_LSTAT               7
+SSH_FXP_FSTAT               8
+SSH_FXP_SETSTAT             9
+SSH_FXP_FSETSTAT           10
+SSH_FXP_OPENDIR            11
+SSH_FXP_READDIR            12
+SSH_FXP_REMOVE             13
+SSH_FXP_MKDIR              14
+SSH_FXP_RMDIR              15
+SSH_FXP_REALPATH           16
+SSH_FXP_STAT               17
+SSH_FXP_RENAME             18
+SSH_FXP_READLINK           19
+SSH_FXP_SYMLINK            20 // Deprecated in filexfer-13 added from filexfer-02
+SSH_FXP_LINK               21
+SSH_FXP_BLOCK              22
+SSH_FXP_UNBLOCK            23
+
+SSH_FXP_STATUS            101
+SSH_FXP_HANDLE            102
+SSH_FXP_DATA              103
+SSH_FXP_NAME              104
+SSH_FXP_ATTRS             105
+
+SSH_FXP_EXTENDED          200
+SSH_FXP_EXTENDED_REPLY    201
+`
+
+func TestFxpNames(t *testing.T) {
+	whitespace := regexp.MustCompile(`[[:space:]]+`)
+
+	scan := bufio.NewScanner(strings.NewReader(fxpStandardsText))
+
+	for scan.Scan() {
+		line := scan.Text()
+		if i := strings.Index(line, "//"); i >= 0 {
+			line = line[:i]
+		}
+
+		line = strings.TrimSpace(line)
+		if line == "" {
+			continue
+		}
+
+		fields := whitespace.Split(line, 2)
+		if len(fields) < 2 {
+			t.Fatalf("unexpected standards text line: %q", line)
+		}
+
+		name, value := fields[0], fields[1]
+		n, err := strconv.Atoi(value)
+		if err != nil {
+			t.Fatal("unexpected error:", err)
+		}
+
+		fxp := PacketType(n)
+
+		if got := fxp.String(); got != name {
+			t.Errorf("fxp name mismatch for %d: got %q, but want %q", n, got, name)
+		}
+	}
+
+	if err := scan.Err(); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/handle_packets_test.go b/internal/encoding/ssh/filexfer/handle_packets_test.go
new file mode 100644
index 0000000..10fdc53
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/handle_packets_test.go
@@ -0,0 +1,282 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+var _ Packet = &ClosePacket{}
+
+func TestClosePacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+	)
+
+	p := &ClosePacket{
+		Handle: "somehandle",
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 19,
+		4,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ClosePacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+}
+
+var _ Packet = &ReadPacket{}
+
+func TestReadPacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+		offset = 0x123456789ABCDEF0
+		length = 0xFEDCBA98
+	)
+
+	p := &ReadPacket{
+		Handle: "somehandle",
+		Offset: offset,
+		Len:    length,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 31,
+		5,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+		0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
+		0xFE, 0xDC, 0xBA, 0x98,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ReadPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+
+	if p.Offset != offset {
+		t.Errorf("UnmarshalPacketBody(): Offset was %x, but expected %x", p.Offset, offset)
+	}
+
+	if p.Len != length {
+		t.Errorf("UnmarshalPacketBody(): Len was %x, but expected %x", p.Len, length)
+	}
+}
+
+var _ Packet = &WritePacket{}
+
+func TestWritePacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+		offset = 0x123456789ABCDEF0
+	)
+
+	var payload = []byte(`foobar`)
+
+	p := &WritePacket{
+		Handle: "somehandle",
+		Offset: offset,
+		Data:   payload,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 37,
+		6,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+		0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0,
+		0x00, 0x00, 0x00, 0x06, 'f', 'o', 'o', 'b', 'a', 'r',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = WritePacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+
+	if p.Offset != offset {
+		t.Errorf("UnmarshalPacketBody(): Offset was %x, but expected %x", p.Offset, offset)
+	}
+
+	if !bytes.Equal(p.Data, payload) {
+		t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", p.Data, payload)
+	}
+}
+
+var _ Packet = &FStatPacket{}
+
+func TestFStatPacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+	)
+
+	p := &FStatPacket{
+		Handle: "somehandle",
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 19,
+		8,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = FStatPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+}
+
+var _ Packet = &FSetstatPacket{}
+
+func TestFSetstatPacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+		perms  = 0x87654321
+	)
+
+	p := &FSetstatPacket{
+		Handle: "somehandle",
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 27,
+		10,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = FSetstatPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+}
+
+var _ Packet = &ReadDirPacket{}
+
+func TestReadDirPacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+	)
+
+	p := &ReadDirPacket{
+		Handle: "somehandle",
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 19,
+		12,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ReadDirPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/init_packets_test.go b/internal/encoding/ssh/filexfer/init_packets_test.go
new file mode 100644
index 0000000..0cab752
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/init_packets_test.go
@@ -0,0 +1,114 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestInitPacket(t *testing.T) {
+	var version uint8 = 3
+
+	p := &InitPacket{
+		Version: uint32(version),
+		Extensions: []*ExtensionPair{
+			{
+				Name: "foo",
+				Data: "1",
+			},
+		},
+	}
+
+	data, err := p.MarshalBinary()
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 17,
+		1,
+		0x00, 0x00, 0x00, version,
+		0x00, 0x00, 0x00, 3, 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 1, '1',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = InitPacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalBinary(data[5:]); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Version != uint32(version) {
+		t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version)
+	}
+
+	if len(p.Extensions) != 1 {
+		t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1)
+	}
+
+	if got, want := p.Extensions[0].Name, "foo"; got != want {
+		t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want)
+	}
+
+	if got, want := p.Extensions[0].Data, "1"; got != want {
+		t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want)
+	}
+}
+
+func TestVersionPacket(t *testing.T) {
+	var version uint8 = 3
+
+	p := &VersionPacket{
+		Version: uint32(version),
+		Extensions: []*ExtensionPair{
+			{
+				Name: "foo",
+				Data: "1",
+			},
+		},
+	}
+
+	data, err := p.MarshalBinary()
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 17,
+		1,
+		0x00, 0x00, 0x00, version,
+		0x00, 0x00, 0x00, 3, 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 1, '1',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = VersionPacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalBinary(data[5:]); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Version != uint32(version) {
+		t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version)
+	}
+
+	if len(p.Extensions) != 1 {
+		t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1)
+	}
+
+	if got, want := p.Extensions[0].Name, "foo"; got != want {
+		t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want)
+	}
+
+	if got, want := p.Extensions[0].Data, "1"; got != want {
+		t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/open_packets_test.go b/internal/encoding/ssh/filexfer/open_packets_test.go
new file mode 100644
index 0000000..8637cd0
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/open_packets_test.go
@@ -0,0 +1,107 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+var _ Packet = &OpenPacket{}
+
+func TestOpenPacket(t *testing.T) {
+	const (
+		id       = 42
+		filename = "/foo"
+		perms    = 0x87654321
+	)
+
+	p := &OpenPacket{
+		Filename: "/foo",
+		PFlags:   FlagRead,
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 25,
+		3,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 1,
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = OpenPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Filename != filename {
+		t.Errorf("UnmarshalPacketBody(): Filename was %q, but expected %q", p.Filename, filename)
+	}
+
+	if p.PFlags != FlagRead {
+		t.Errorf("UnmarshalPacketBody(): PFlags was %#x, but expected %#x", p.PFlags, FlagRead)
+	}
+
+	if p.Attrs.Flags != AttrPermissions {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions)
+	}
+
+	if p.Attrs.Permissions != perms {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms)
+	}
+}
+
+var _ Packet = &OpenDirPacket{}
+
+func TestOpenDirPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &OpenDirPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		11,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = OpenDirPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/packets_test.go b/internal/encoding/ssh/filexfer/packets_test.go
new file mode 100644
index 0000000..8658e04
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/packets_test.go
@@ -0,0 +1,132 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+func TestRawPacket(t *testing.T) {
+	const (
+		id      = 42
+		errMsg  = "eof"
+		langTag = "en"
+	)
+
+	p := &RawPacket{
+		Type:      PacketTypeStatus,
+		RequestID: id,
+		Data: Buffer{
+			b: []byte{
+				0x00, 0x00, 0x00, 0x01,
+				0x00, 0x00, 0x00, 0x03, 'e', 'o', 'f',
+				0x00, 0x00, 0x00, 0x02, 'e', 'n',
+			},
+		},
+	}
+
+	data, err := p.MarshalBinary()
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 22,
+		101,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 0x01,
+		0x00, 0x00, 0x00, 3, 'e', 'o', 'f',
+		0x00, 0x00, 0x00, 2, 'e', 'n',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Errorf("RawPacket.Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RawPacket{}
+
+	if err := p.ReadFrom(bytes.NewReader(data), nil, DefaultMaxPacketLength); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Type != PacketTypeStatus {
+		t.Errorf("RawPacket.UnmarshalBinary(): Type was %v, but expected %v", p.Type, PacketTypeStat)
+	}
+
+	if p.RequestID != uint32(id) {
+		t.Errorf("RawPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id)
+	}
+
+	want = []byte{
+		0x00, 0x00, 0x00, 0x01,
+		0x00, 0x00, 0x00, 3, 'e', 'o', 'f',
+		0x00, 0x00, 0x00, 2, 'e', 'n',
+	}
+
+	if !bytes.Equal(p.Data.Bytes(), want) {
+		t.Fatalf("RawPacket.UnmarshalBinary(): Data was %X, but expected %X", p.Data, want)
+	}
+
+	var resp StatusPacket
+	resp.UnmarshalPacketBody(&p.Data)
+
+	if resp.StatusCode != StatusEOF {
+		t.Errorf("UnmarshalPacketBody(RawPacket.Data): StatusCode was %v, but expected %v", resp.StatusCode, StatusEOF)
+	}
+
+	if resp.ErrorMessage != errMsg {
+		t.Errorf("UnmarshalPacketBody(RawPacket.Data): ErrorMessage was %q, but expected %q", resp.ErrorMessage, errMsg)
+	}
+
+	if resp.LanguageTag != langTag {
+		t.Errorf("UnmarshalPacketBody(RawPacket.Data): LanguageTag was %q, but expected %q", resp.LanguageTag, langTag)
+	}
+}
+
+func TestRequestPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "foo"
+	)
+
+	p := &RequestPacket{
+		RequestID: id,
+		Request: &StatPacket{
+			Path: path,
+		},
+	}
+
+	data, err := p.MarshalBinary()
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 12,
+		17,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 3, 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Errorf("RequestPacket.Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RequestPacket{}
+
+	if err := p.ReadFrom(bytes.NewReader(data), nil, DefaultMaxPacketLength); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.RequestID != uint32(id) {
+		t.Errorf("RequestPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id)
+	}
+
+	req, ok := p.Request.(*StatPacket)
+	if !ok {
+		t.Fatalf("unexpected Request type was %T, but expected %T", p.Request, req)
+	}
+
+	if req.Path != path {
+		t.Errorf("RequestPacket.UnmarshalBinary(): Request.Path was %q, but expected %q", req.Path, path)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/path_packets_test.go b/internal/encoding/ssh/filexfer/path_packets_test.go
new file mode 100644
index 0000000..852145e
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/path_packets_test.go
@@ -0,0 +1,450 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+var _ Packet = &LStatPacket{}
+
+func TestLStatPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &LStatPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		7,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = LStatPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &SetstatPacket{}
+
+func TestSetstatPacket(t *testing.T) {
+	const (
+		id    = 42
+		path  = "/foo"
+		perms = 0x87654321
+	)
+
+	p := &SetstatPacket{
+		Path: "/foo",
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 21,
+		9,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = SetstatPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+
+	if p.Attrs.Flags != AttrPermissions {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions)
+	}
+
+	if p.Attrs.Permissions != perms {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms)
+	}
+}
+
+var _ Packet = &RemovePacket{}
+
+func TestRemovePacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &RemovePacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		13,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RemovePacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &MkdirPacket{}
+
+func TestMkdirPacket(t *testing.T) {
+	const (
+		id    = 42
+		path  = "/foo"
+		perms = 0x87654321
+	)
+
+	p := &MkdirPacket{
+		Path: "/foo",
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 21,
+		14,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = MkdirPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+
+	if p.Attrs.Flags != AttrPermissions {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions)
+	}
+
+	if p.Attrs.Permissions != perms {
+		t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms)
+	}
+}
+
+var _ Packet = &RmdirPacket{}
+
+func TestRmdirPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &RmdirPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		15,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RmdirPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &RealPathPacket{}
+
+func TestRealPathPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &RealPathPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		16,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RealPathPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &StatPacket{}
+
+func TestStatPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &StatPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		17,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = StatPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &RenamePacket{}
+
+func TestRenamePacket(t *testing.T) {
+	const (
+		id      = 42
+		oldpath = "/foo"
+		newpath = "/bar"
+	)
+
+	p := &RenamePacket{
+		OldPath: oldpath,
+		NewPath: newpath,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 21,
+		18,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = RenamePacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.OldPath != oldpath {
+		t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", p.OldPath, oldpath)
+	}
+
+	if p.NewPath != newpath {
+		t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", p.NewPath, newpath)
+	}
+}
+
+var _ Packet = &ReadLinkPacket{}
+
+func TestReadLinkPacket(t *testing.T) {
+	const (
+		id   = 42
+		path = "/foo"
+	)
+
+	p := &ReadLinkPacket{
+		Path: path,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		19,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = ReadLinkPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Path != path {
+		t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path)
+	}
+}
+
+var _ Packet = &SymlinkPacket{}
+
+func TestSymlinkPacket(t *testing.T) {
+	const (
+		id         = 42
+		linkpath   = "/foo"
+		targetpath = "/bar"
+	)
+
+	p := &SymlinkPacket{
+		LinkPath:   linkpath,
+		TargetPath: targetpath,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 21,
+		20,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r', // Arguments were inadvertently reversed.
+		0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = SymlinkPacket{}
+
+	// UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.LinkPath != linkpath {
+		t.Errorf("UnmarshalPacketBody(): LinkPath was %q, but expected %q", p.LinkPath, linkpath)
+	}
+
+	if p.TargetPath != targetpath {
+		t.Errorf("UnmarshalPacketBody(): TargetPath was %q, but expected %q", p.TargetPath, targetpath)
+	}
+}
diff --git a/internal/encoding/ssh/filexfer/response_packets_test.go b/internal/encoding/ssh/filexfer/response_packets_test.go
new file mode 100644
index 0000000..23e42bb
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/response_packets_test.go
@@ -0,0 +1,274 @@
+package filexfer
+
+import (
+	"bytes"
+	"testing"
+)
+
+var _ Packet = &StatusPacket{}
+
+func TestStatusPacket(t *testing.T) {
+	const (
+		id           = 42
+		statusCode   = StatusBadMessage
+		errorMessage = "foo"
+		languageTag  = "x-example"
+	)
+
+	p := &StatusPacket{
+		StatusCode:   statusCode,
+		ErrorMessage: errorMessage,
+		LanguageTag:  languageTag,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 29,
+		101,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 5,
+		0x00, 0x00, 0x00, 3, 'f', 'o', 'o',
+		0x00, 0x00, 0x00, 9, 'x', '-', 'e', 'x', 'a', 'm', 'p', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = StatusPacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.StatusCode != statusCode {
+		t.Errorf("UnmarshalBinary(): StatusCode was %v, but expected %v", p.StatusCode, statusCode)
+	}
+
+	if p.ErrorMessage != errorMessage {
+		t.Errorf("UnmarshalBinary(): ErrorMessage was %q, but expected %q", p.ErrorMessage, errorMessage)
+	}
+
+	if p.LanguageTag != languageTag {
+		t.Errorf("UnmarshalBinary(): LanguageTag was %q, but expected %q", p.LanguageTag, languageTag)
+	}
+}
+
+var _ Packet = &HandlePacket{}
+
+func TestHandlePacket(t *testing.T) {
+	const (
+		id     = 42
+		handle = "somehandle"
+	)
+
+	p := &HandlePacket{
+		Handle: "somehandle",
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 19,
+		102,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = HandlePacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Handle != handle {
+		t.Errorf("UnmarshalBinary(): Handle was %q, but expected %q", p.Handle, handle)
+	}
+}
+
+var _ Packet = &DataPacket{}
+
+func TestDataPacket(t *testing.T) {
+	const (
+		id = 42
+	)
+
+	var payload = []byte(`foobar`)
+
+	p := &DataPacket{
+		Data: payload,
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 15,
+		103,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 6, 'f', 'o', 'o', 'b', 'a', 'r',
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = DataPacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if !bytes.Equal(p.Data, payload) {
+		t.Errorf("UnmarshalBinary(): Data was %X, but expected %X", p.Data, payload)
+	}
+}
+
+var _ Packet = &NamePacket{}
+
+func TestNamePacket(t *testing.T) {
+	const (
+		id       = 42
+		filename = "foo"
+		longname = "bar"
+		perms    = 0x87654300
+	)
+
+	p := &NamePacket{
+		Entries: []*NameEntry{
+			&NameEntry{
+				Filename: filename + "1",
+				Longname: longname + "1",
+				Attrs: Attributes{
+					Flags:       AttrPermissions | (1 << 8),
+					Permissions: perms | 1,
+				},
+			},
+			&NameEntry{
+				Filename: filename + "2",
+				Longname: longname + "2",
+				Attrs: Attributes{
+					Flags:       AttrPermissions | (2 << 8),
+					Permissions: perms | 2,
+				},
+			},
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 57,
+		104,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 0x02,
+		0x00, 0x00, 0x00, 4, 'f', 'o', 'o', '1',
+		0x00, 0x00, 0x00, 4, 'b', 'a', 'r', '1',
+		0x00, 0x00, 0x01, 0x04,
+		0x87, 0x65, 0x43, 0x01,
+		0x00, 0x00, 0x00, 4, 'f', 'o', 'o', '2',
+		0x00, 0x00, 0x00, 4, 'b', 'a', 'r', '2',
+		0x00, 0x00, 0x02, 0x04,
+		0x87, 0x65, 0x43, 0x02,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = NamePacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if count := len(p.Entries); count != 2 {
+		t.Fatalf("UnmarshalBinary(): len(NameEntries) was %d, but expected %d", count, 2)
+	}
+
+	for i, e := range p.Entries {
+		if got, want := e.Filename, filename+string('1'+rune(i)); got != want {
+			t.Errorf("UnmarshalBinary(): Entries[%d].Filename was %q, but expected %q", i, got, want)
+		}
+
+		if got, want := e.Longname, longname+string('1'+rune(i)); got != want {
+			t.Errorf("UnmarshalBinary(): Entries[%d].Longname was %q, but expected %q", i, got, want)
+		}
+
+		if got, want := e.Attrs.Flags, AttrPermissions|((i+1)<<8); got != uint32(want) {
+			t.Errorf("UnmarshalBinary(): Entries[%d].Attrs.Flags was %#x, but expected %#x", i, got, want)
+		}
+
+		if got, want := e.Attrs.Permissions, FileMode(perms|(i+1)); got != want {
+			t.Errorf("UnmarshalBinary(): Entries[%d].Attrs.Permissions was %#v, but expected %#v", i, got, want)
+		}
+	}
+}
+
+var _ Packet = &AttrsPacket{}
+
+func TestAttrsPacket(t *testing.T) {
+	const (
+		id    = 42
+		perms = 0x87654321
+	)
+
+	p := &AttrsPacket{
+		Attrs: Attributes{
+			Flags:       AttrPermissions,
+			Permissions: perms,
+		},
+	}
+
+	data, err := ComposePacket(p.MarshalPacket(id, nil))
+	if err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	want := []byte{
+		0x00, 0x00, 0x00, 13,
+		105,
+		0x00, 0x00, 0x00, 42,
+		0x00, 0x00, 0x00, 0x04,
+		0x87, 0x65, 0x43, 0x21,
+	}
+
+	if !bytes.Equal(data, want) {
+		t.Fatalf("Marshal() = %X, but wanted %X", data, want)
+	}
+
+	*p = AttrsPacket{}
+
+	// UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+	if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil {
+		t.Fatal("unexpected error:", err)
+	}
+
+	if p.Attrs.Flags != AttrPermissions {
+		t.Errorf("UnmarshalBinary(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions)
+	}
+
+	if p.Attrs.Permissions != perms {
+		t.Errorf("UnmarshalBinary(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms)
+	}
+}
diff --git a/packet_test.go b/packet_test.go
index c7deb5a..976f66f 100644
--- a/packet_test.go
+++ b/packet_test.go
@@ -3,174 +3,120 @@ package sftp
 import (
 	"bytes"
 	"encoding"
-	"errors"
-	"io/ioutil"
 	"os"
 	"testing"
 )
 
-func TestMarshalUint32(t *testing.T) {
-	var tests = []struct {
-		v    uint32
-		want []byte
-	}{
-		{0, []byte{0, 0, 0, 0}},
-		{42, []byte{0, 0, 0, 42}},
-		{42 << 8, []byte{0, 0, 42, 0}},
-		{42 << 16, []byte{0, 42, 0, 0}},
-		{42 << 24, []byte{42, 0, 0, 0}},
-		{^uint32(0), []byte{255, 255, 255, 255}},
-	}
+var marshalUint32Tests = []struct {
+	v    uint32
+	want []byte
+}{
+	{1, []byte{0, 0, 0, 1}},
+	{256, []byte{0, 0, 1, 0}},
+	{^uint32(0), []byte{255, 255, 255, 255}},
+}
 
-	for _, tt := range tests {
+func TestMarshalUint32(t *testing.T) {
+	for _, tt := range marshalUint32Tests {
 		got := marshalUint32(nil, tt.v)
 		if !bytes.Equal(tt.want, got) {
-			t.Errorf("marshalUint32(%d) = %#v, want %#v", tt.v, got, tt.want)
+			t.Errorf("marshalUint32(%d): want %v, got %v", tt.v, tt.want, got)
 		}
 	}
 }
 
-func TestMarshalUint64(t *testing.T) {
-	var tests = []struct {
-		v    uint64
-		want []byte
-	}{
-		{0, []byte{0, 0, 0, 0, 0, 0, 0, 0}},
-		{42, []byte{0, 0, 0, 0, 0, 0, 0, 42}},
-		{42 << 8, []byte{0, 0, 0, 0, 0, 0, 42, 0}},
-		{42 << 16, []byte{0, 0, 0, 0, 0, 42, 0, 0}},
-		{42 << 24, []byte{0, 0, 0, 0, 42, 0, 0, 0}},
-		{42 << 32, []byte{0, 0, 0, 42, 0, 0, 0, 0}},
-		{42 << 40, []byte{0, 0, 42, 0, 0, 0, 0, 0}},
-		{42 << 48, []byte{0, 42, 0, 0, 0, 0, 0, 0}},
-		{42 << 56, []byte{42, 0, 0, 0, 0, 0, 0, 0}},
-		{^uint64(0), []byte{255, 255, 255, 255, 255, 255, 255, 255}},
-	}
+var marshalUint64Tests = []struct {
+	v    uint64
+	want []byte
+}{
+	{1, []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1}},
+	{256, []byte{0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x1, 0x0}},
+	{^uint64(0), []byte{0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff, 0xff}},
+	{1 << 32, []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}},
+}
 
-	for _, tt := range tests {
+func TestMarshalUint64(t *testing.T) {
+	for _, tt := range marshalUint64Tests {
 		got := marshalUint64(nil, tt.v)
 		if !bytes.Equal(tt.want, got) {
-			t.Errorf("marshalUint64(%d) = %#v, want %#v", tt.v, got, tt.want)
+			t.Errorf("marshalUint64(%d): want %#v, got %#v", tt.v, tt.want, got)
 		}
 	}
 }
 
-func TestMarshalString(t *testing.T) {
-	var tests = []struct {
-		v    string
-		want []byte
-	}{
-		{"", []byte{0, 0, 0, 0}},
-		{"/", []byte{0x0, 0x0, 0x0, 0x01, '/'}},
-		{"/foo", []byte{0x0, 0x0, 0x0, 0x4, '/', 'f', 'o', 'o'}},
-		{"\x00bar", []byte{0x0, 0x0, 0x0, 0x4, 0, 'b', 'a', 'r'}},
-		{"b\x00ar", []byte{0x0, 0x0, 0x0, 0x4, 'b', 0, 'a', 'r'}},
-		{"ba\x00r", []byte{0x0, 0x0, 0x0, 0x4, 'b', 'a', 0, 'r'}},
-		{"bar\x00", []byte{0x0, 0x0, 0x0, 0x4, 'b', 'a', 'r', 0}},
-	}
+var marshalStringTests = []struct {
+	v    string
+	want []byte
+}{
+	{"", []byte{0, 0, 0, 0}},
+	{"/foo", []byte{0x0, 0x0, 0x0, 0x4, 0x2f, 0x66, 0x6f, 0x6f}},
+}
 
-	for _, tt := range tests {
+func TestMarshalString(t *testing.T) {
+	for _, tt := range marshalStringTests {
 		got := marshalString(nil, tt.v)
 		if !bytes.Equal(tt.want, got) {
-			t.Errorf("marshalString(%q) = %#v, want %#v", tt.v, got, tt.want)
+			t.Errorf("marshalString(%q): want %#v, got %#v", tt.v, tt.want, got)
 		}
 	}
 }
 
-func TestMarshal(t *testing.T) {
-	type Struct struct {
-		X, Y, Z uint32
-	}
-
-	var tests = []struct {
-		v    interface{}
-		want []byte
-	}{
-		{uint8(42), []byte{42}},
-		{uint32(42 << 8), []byte{0, 0, 42, 0}},
-		{uint64(42 << 32), []byte{0, 0, 0, 42, 0, 0, 0, 0}},
-		{"foo", []byte{0x0, 0x0, 0x0, 0x3, 'f', 'o', 'o'}},
-		{Struct{1, 2, 3}, []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x3}},
-		{[]uint32{1, 2, 3}, []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x3}},
-	}
+var marshalTests = []struct {
+	v    interface{}
+	want []byte
+}{
+	{uint8(1), []byte{1}},
+	{byte(1), []byte{1}},
+	{uint32(1), []byte{0, 0, 0, 1}},
+	{uint64(1), []byte{0, 0, 0, 0, 0, 0, 0, 1}},
+	{"foo", []byte{0x0, 0x0, 0x0, 0x3, 0x66, 0x6f, 0x6f}},
+	{[]uint32{1, 2, 3, 4}, []byte{0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x0, 0x3, 0x0, 0x0, 0x0, 0x4}},
+}
 
-	for _, tt := range tests {
+func TestMarshal(t *testing.T) {
+	for _, tt := range marshalTests {
 		got := marshal(nil, tt.v)
 		if !bytes.Equal(tt.want, got) {
-			t.Errorf("marshal(%#v) = %#v, want %#v", tt.v, got, tt.want)
+			t.Errorf("marshal(%v): want %#v, got %#v", tt.v, tt.want, got)
 		}
 	}
 }
 
-func TestUnmarshalUint32(t *testing.T) {
-	testBuffer := []byte{
-		0, 0, 0, 0,
-		0, 0, 0, 42,
-		0, 0, 42, 0,
-		0, 42, 0, 0,
-		42, 0, 0, 0,
-		255, 0, 0, 254,
-	}
-
-	var wants = []uint32{
-		0,
-		42,
-		42 << 8,
-		42 << 16,
-		42 << 24,
-		255<<24 | 254,
-	}
-
-	var i int
-	for len(testBuffer) > 0 {
-		got, rest := unmarshalUint32(testBuffer)
+var unmarshalUint32Tests = []struct {
+	b    []byte
+	want uint32
+	rest []byte
+}{
+	{[]byte{0, 0, 0, 0}, 0, nil},
+	{[]byte{0, 0, 1, 0}, 256, nil},
+	{[]byte{255, 0, 0, 255}, 4278190335, nil},
+}
 
-		if got != wants[i] {
-			t.Fatalf("unmarshalUint32(%#v) = %d, want %d", testBuffer[:4], got, wants[i])
+func TestUnmarshalUint32(t *testing.T) {
+	for _, tt := range unmarshalUint32Tests {
+		got, rest := unmarshalUint32(tt.b)
+		if got != tt.want || !bytes.Equal(rest, tt.rest) {
+			t.Errorf("unmarshalUint32(%v): want %v, %#v, got %v, %#v", tt.b, tt.want, tt.rest, got, rest)
 		}
-
-		i++
-		testBuffer = rest
 	}
 }
 
-func TestUnmarshalUint64(t *testing.T) {
-	testBuffer := []byte{
-		0, 0, 0, 0, 0, 0, 0, 0,
-		0, 0, 0, 0, 0, 0, 0, 42,
-		0, 0, 0, 0, 0, 0, 42, 0,
-		0, 0, 0, 0, 0, 42, 0, 0,
-		0, 0, 0, 0, 42, 0, 0, 0,
-		0, 0, 0, 42, 0, 0, 0, 0,
-		0, 0, 42, 0, 0, 0, 0, 0,
-		0, 42, 0, 0, 0, 0, 0, 0,
-		42, 0, 0, 0, 0, 0, 0, 0,
-		255, 0, 0, 0, 0, 0, 0, 254,
-	}
-
-	var wants = []uint64{
-		0,
-		42,
-		42 << 8,
-		42 << 16,
-		42 << 24,
-		42 << 32,
-		42 << 40,
-		42 << 48,
-		42 << 56,
-		255<<56 | 254,
-	}
-
-	var i int
-	for len(testBuffer) > 0 {
-		got, rest := unmarshalUint64(testBuffer)
+var unmarshalUint64Tests = []struct {
+	b    []byte
+	want uint64
+	rest []byte
+}{
+	{[]byte{0, 0, 0, 0, 0, 0, 0, 0}, 0, nil},
+	{[]byte{0, 0, 0, 0, 0, 0, 1, 0}, 256, nil},
+	{[]byte{255, 0, 0, 0, 0, 0, 0, 255}, 18374686479671623935, nil},
+}
 
-		if got != wants[i] {
-			t.Fatalf("unmarshalUint64(%#v) = %d, want %d", testBuffer[:8], got, wants[i])
+func TestUnmarshalUint64(t *testing.T) {
+	for _, tt := range unmarshalUint64Tests {
+		got, rest := unmarshalUint64(tt.b)
+		if got != tt.want || !bytes.Equal(rest, tt.rest) {
+			t.Errorf("unmarshalUint64(%v): want %v, %#v, got %v, %#v", tt.b, tt.want, tt.rest, got, rest)
 		}
-
-		i++
-		testBuffer = rest
 	}
 }
 
@@ -184,191 +130,85 @@ var unmarshalStringTests = []struct {
 }
 
 func TestUnmarshalString(t *testing.T) {
-	testBuffer := []byte{
-		0, 0, 0, 0,
-		0, 0, 0, 1, '/',
-		0, 0, 0, 4, '/', 'f', 'o', 'o',
-		0, 0, 0, 4, 0, 'b', 'a', 'r',
-		0, 0, 0, 4, 'b', 0, 'a', 'r',
-		0, 0, 0, 4, 'b', 'a', 0, 'r',
-		0, 0, 0, 4, 'b', 'a', 'r', 0,
-	}
-
-	var wants = []string{
-		"",
-		"/",
-		"/foo",
-		"\x00bar",
-		"b\x00ar",
-		"ba\x00r",
-		"bar\x00",
-	}
-
-	var i int
-	for len(testBuffer) > 0 {
-		got, rest := unmarshalString(testBuffer)
-
-		if got != wants[i] {
-			t.Fatalf("unmarshalUint64(%#v...) = %q, want %q", testBuffer[:4], got, wants[i])
+	for _, tt := range unmarshalStringTests {
+		got, rest := unmarshalString(tt.b)
+		if got != tt.want || !bytes.Equal(rest, tt.rest) {
+			t.Errorf("unmarshalUint64(%v): want %q, %#v, got %q, %#v", tt.b, tt.want, tt.rest, got, rest)
 		}
-
-		i++
-		testBuffer = rest
 	}
 }
 
-func TestSendPacket(t *testing.T) {
-	var tests = []struct {
-		packet encoding.BinaryMarshaler
-		want   []byte
-	}{
-		{
-			packet: &sshFxInitPacket{
-				Version: 3,
-				Extensions: []extensionPair{
-					{"posix-rename@openssh.com", "1"},
-				},
-			},
-			want: []byte{
-				0x0, 0x0, 0x0, 0x26,
-				0x1,
-				0x0, 0x0, 0x0, 0x3,
-				0x0, 0x0, 0x0, 0x18,
-				'p', 'o', 's', 'i', 'x', '-', 'r', 'e', 'n', 'a', 'm', 'e', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
-				0x0, 0x0, 0x0, 0x1,
-				'1',
-			},
-		},
-		{
-			packet: &sshFxpOpenPacket{
-				ID:     1,
-				Path:   "/foo",
-				Pflags: flags(os.O_RDONLY),
-			},
-			want: []byte{
-				0x0, 0x0, 0x0, 0x15,
-				0x3,
-				0x0, 0x0, 0x0, 0x1,
-				0x0, 0x0, 0x0, 0x4, '/', 'f', 'o', 'o',
-				0x0, 0x0, 0x0, 0x1,
-				0x0, 0x0, 0x0, 0x0,
-			},
-		},
-		{
-			packet: &sshFxpWritePacket{
-				ID:     124,
-				Handle: "foo",
-				Offset: 13,
-				Length: uint32(len("bar")),
-				Data:   []byte("bar"),
-			},
-			want: []byte{
-				0x0, 0x0, 0x0, 0x1b,
-				0x6,
-				0x0, 0x0, 0x0, 0x7c,
-				0x0, 0x0, 0x0, 0x3, 'f', 'o', 'o',
-				0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd,
-				0x0, 0x0, 0x0, 0x3, 'b', 'a', 'r',
-			},
-		},
-		{
-			packet: &sshFxpSetstatPacket{
-				ID:    31,
-				Path:  "/bar",
-				Flags: sshFileXferAttrUIDGID,
-				Attrs: struct {
-					UID uint32
-					GID uint32
-				}{
-					UID: 1000,
-					GID: 100,
-				},
-			},
-			want: []byte{
-				0x0, 0x0, 0x0, 0x19,
-				0x9,
-				0x0, 0x0, 0x0, 0x1f,
-				0x0, 0x0, 0x0, 0x4, '/', 'b', 'a', 'r',
-				0x0, 0x0, 0x0, 0x2,
-				0x0, 0x0, 0x3, 0xe8,
-				0x0, 0x0, 0x0, 0x64,
-			},
+var sendPacketTests = []struct {
+	p    encoding.BinaryMarshaler
+	want []byte
+}{
+	{&sshFxInitPacket{
+		Version: 3,
+		Extensions: []extensionPair{
+			{"posix-rename@openssh.com", "1"},
 		},
-	}
+	}, []byte{0x0, 0x0, 0x0, 0x26, 0x1, 0x0, 0x0, 0x0, 0x3, 0x0, 0x0, 0x0, 0x18, 0x70, 0x6f, 0x73, 0x69, 0x78, 0x2d, 0x72, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x73, 0x73, 0x68, 0x2e, 0x63, 0x6f, 0x6d, 0x0, 0x0, 0x0, 0x1, 0x31}},
 
-	for _, tt := range tests {
-		b := new(bytes.Buffer)
-		sendPacket(b, tt.packet)
-		if got := b.Bytes(); !bytes.Equal(tt.want, got) {
-			t.Errorf("sendPacket(%v): got %x want %x", tt.packet, tt.want, got)
+	{&sshFxpOpenPacket{
+		ID:     1,
+		Path:   "/foo",
+		Pflags: flags(os.O_RDONLY),
+	}, []byte{0x0, 0x0, 0x0, 0x15, 0x3, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x4, 0x2f, 0x66, 0x6f, 0x6f, 0x0, 0x0, 0x0, 0x1, 0x0, 0x0, 0x0, 0x0}},
+
+	{&sshFxpWritePacket{
+		ID:     124,
+		Handle: "foo",
+		Offset: 13,
+		Length: uint32(len([]byte("bar"))),
+		Data:   []byte("bar"),
+	}, []byte{0x0, 0x0, 0x0, 0x1b, 0x6, 0x0, 0x0, 0x0, 0x7c, 0x0, 0x0, 0x0, 0x3, 0x66, 0x6f, 0x6f, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0x0, 0xd, 0x0, 0x0, 0x0, 0x3, 0x62, 0x61, 0x72}},
+
+	{&sshFxpSetstatPacket{
+		ID:    31,
+		Path:  "/bar",
+		Flags: flags(os.O_WRONLY),
+		Attrs: struct {
+			UID uint32
+			GID uint32
+		}{1000, 100},
+	}, []byte{0x0, 0x0, 0x0, 0x19, 0x9, 0x0, 0x0, 0x0, 0x1f, 0x0, 0x0, 0x0, 0x4, 0x2f, 0x62, 0x61, 0x72, 0x0, 0x0, 0x0, 0x2, 0x0, 0x0, 0x3, 0xe8, 0x0, 0x0, 0x0, 0x64}},
+}
+
+func TestSendPacket(t *testing.T) {
+	for _, tt := range sendPacketTests {
+		var w bytes.Buffer
+		sendPacket(&w, tt.p)
+		if got := w.Bytes(); !bytes.Equal(tt.want, got) {
+			t.Errorf("sendPacket(%v): want %#v, got %#v", tt.p, tt.want, got)
 		}
 	}
 }
 
-func sp(data encoding.BinaryMarshaler) []byte {
-	b := new(bytes.Buffer)
-	sendPacket(b, data)
-	return b.Bytes()
+func sp(p encoding.BinaryMarshaler) []byte {
+	var w bytes.Buffer
+	sendPacket(&w, p)
+	return w.Bytes()
 }
 
-func TestRecvPacket(t *testing.T) {
-	var recvPacketTests = []struct {
-		b []byte
-
-		want    uint8
-		body    []byte
-		wantErr error
-	}{
-		{
-			b: sp(&sshFxInitPacket{
-				Version: 3,
-				Extensions: []extensionPair{
-					{"posix-rename@openssh.com", "1"},
-				},
-			}),
-			want: sshFxpInit,
-			body: []byte{
-				0x0, 0x0, 0x0, 0x3,
-				0x0, 0x0, 0x0, 0x18,
-				'p', 'o', 's', 'i', 'x', '-', 'r', 'e', 'n', 'a', 'm', 'e', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
-				0x0, 0x0, 0x0, 0x01,
-				'1',
-			},
-		},
-		{
-			b: []byte{
-				0x0, 0x0, 0x0, 0x0,
-			},
-			wantErr: errShortPacket,
-		},
-		{
-			b: []byte{
-				0xff, 0xff, 0xff, 0xff,
-			},
-			wantErr: errLongPacket,
+var recvPacketTests = []struct {
+	b    []byte
+	want uint8
+	rest []byte
+}{
+	{sp(&sshFxInitPacket{
+		Version: 3,
+		Extensions: []extensionPair{
+			{"posix-rename@openssh.com", "1"},
 		},
-	}
+	}), sshFxpInit, []byte{0x0, 0x0, 0x0, 0x3, 0x0, 0x0, 0x0, 0x18, 0x70, 0x6f, 0x73, 0x69, 0x78, 0x2d, 0x72, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x40, 0x6f, 0x70, 0x65, 0x6e, 0x73, 0x73, 0x68, 0x2e, 0x63, 0x6f, 0x6d, 0x0, 0x0, 0x0, 0x1, 0x31}},
+}
 
+func TestRecvPacket(t *testing.T) {
 	for _, tt := range recvPacketTests {
 		r := bytes.NewReader(tt.b)
-
-		got, body, err := recvPacket(r, nil, 0)
-		if tt.wantErr == nil {
-			if err != nil {
-				t.Fatalf("recvPacket(%#v): unexpected error: %v", tt.b, err)
-			}
-		} else {
-			if !errors.Is(err, tt.wantErr) {
-				t.Fatalf("recvPacket(%#v) = %v, want %v", tt.b, err, tt.wantErr)
-			}
-		}
-
-		if got != tt.want {
-			t.Errorf("recvPacket(%#v) = %#v, want %#v", tt.b, got, tt.want)
-		}
-
-		if !bytes.Equal(body, tt.body) {
-			t.Errorf("recvPacket(%#v) = %#v, want %#v", tt.b, body, tt.body)
+		got, rest, _ := recvPacket(r, nil, 0)
+		if got != tt.want || !bytes.Equal(rest, tt.rest) {
+			t.Errorf("recvPacket(%#v): want %v, %#v, got %v, %#v", tt.b, tt.want, tt.rest, got, rest)
 		}
 	}
 }
@@ -457,51 +297,49 @@ func TestSSHFxpOpenPackethasPflags(t *testing.T) {
 	}
 }
 
-func benchMarshal(b *testing.B, packet encoding.BinaryMarshaler) {
-	b.ResetTimer()
-
+func BenchmarkMarshalInit(b *testing.B) {
 	for i := 0; i < b.N; i++ {
-		sendPacket(ioutil.Discard, packet)
+		sp(&sshFxInitPacket{
+			Version: 3,
+			Extensions: []extensionPair{
+				{"posix-rename@openssh.com", "1"},
+			},
+		})
 	}
 }
 
-func BenchmarkMarshalInit(b *testing.B) {
-	benchMarshal(b, &sshFxInitPacket{
-		Version: 3,
-		Extensions: []extensionPair{
-			{"posix-rename@openssh.com", "1"},
-		},
-	})
-}
-
 func BenchmarkMarshalOpen(b *testing.B) {
-	benchMarshal(b, &sshFxpOpenPacket{
-		ID:     1,
-		Path:   "/home/test/some/random/path",
-		Pflags: flags(os.O_RDONLY),
-	})
+	for i := 0; i < b.N; i++ {
+		sp(&sshFxpOpenPacket{
+			ID:     1,
+			Path:   "/home/test/some/random/path",
+			Pflags: flags(os.O_RDONLY),
+		})
+	}
 }
 
 func BenchmarkMarshalWriteWorstCase(b *testing.B) {
 	data := make([]byte, 32*1024)
-
-	benchMarshal(b, &sshFxpWritePacket{
-		ID:     1,
-		Handle: "someopaquehandle",
-		Offset: 0,
-		Length: uint32(len(data)),
-		Data:   data,
-	})
+	for i := 0; i < b.N; i++ {
+		sp(&sshFxpWritePacket{
+			ID:     1,
+			Handle: "someopaquehandle",
+			Offset: 0,
+			Length: uint32(len(data)),
+			Data:   data,
+		})
+	}
 }
 
 func BenchmarkMarshalWrite1k(b *testing.B) {
-	data := make([]byte, 1025)
-
-	benchMarshal(b, &sshFxpWritePacket{
-		ID:     1,
-		Handle: "someopaquehandle",
-		Offset: 0,
-		Length: uint32(len(data)),
-		Data:   data,
-	})
+	data := make([]byte, 1024)
+	for i := 0; i < b.N; i++ {
+		sp(&sshFxpWritePacket{
+			ID:     1,
+			Handle: "someopaquehandle",
+			Offset: 0,
+			Length: uint32(len(data)),
+			Data:   data,
+		})
+	}
 }
diff --git a/server_integration_test.go b/server_integration_test.go
index 92e2857..c4fb3b9 100644
--- a/server_integration_test.go
+++ b/server_integration_test.go
@@ -408,16 +408,16 @@ func testServer(t *testing.T, useSubsystem bool, readonly bool) (net.Listener, s
 func makeDummyKey() (string, error) {
 	priv, err := ecdsa.GenerateKey(elliptic.P256(), crand.Reader)
 	if err != nil {
-		return "", fmt.Errorf("cannot generate key: %w", err)
+		return "", fmt.Errorf("cannot generate key: %v", err)
 	}
 	der, err := x509.MarshalECPrivateKey(priv)
 	if err != nil {
-		return "", fmt.Errorf("cannot marshal key: %w", err)
+		return "", fmt.Errorf("cannot marshal key: %v", err)
 	}
 	block := &pem.Block{Type: "EC PRIVATE KEY", Bytes: der}
 	f, err := ioutil.TempFile("", "sftp-test-key-")
 	if err != nil {
-		return "", fmt.Errorf("cannot create temp file: %w", err)
+		return "", fmt.Errorf("cannot create temp file: %v", err)
 	}
 	defer func() {
 		if f != nil {
@@ -426,34 +426,16 @@ func makeDummyKey() (string, error) {
 		}
 	}()
 	if err := pem.Encode(f, block); err != nil {
-		return "", fmt.Errorf("cannot write key: %w", err)
+		return "", fmt.Errorf("cannot write key: %v", err)
 	}
 	if err := f.Close(); err != nil {
-		return "", fmt.Errorf("error closing key file: %w", err)
+		return "", fmt.Errorf("error closing key file: %v", err)
 	}
 	path := f.Name()
 	f = nil
 	return path, nil
 }
 
-type execError struct {
-	path   string
-	stderr string
-	err    error
-}
-
-func (e *execError) Error() string {
-	return fmt.Sprintf("%s: %v: %s", e.path, e.err, e.stderr)
-}
-
-func (e *execError) Unwrap() error {
-	return e.err
-}
-
-func (e *execError) Cause() error {
-	return e.err
-}
-
 func runSftpClient(t *testing.T, script string, path string, host string, port int) (string, error) {
 	// if sftp client binary is unavailable, skip test
 	if _, err := os.Stat(*testSftpClientBin); err != nil {
@@ -489,11 +471,7 @@ func runSftpClient(t *testing.T, script string, path string, host string, port i
 	}
 	err = cmd.Wait()
 	if err != nil {
-		err = &execError{
-			path:   cmd.Path,
-			stderr: stderr.String(),
-			err:    err,
-		}
+		err = fmt.Errorf("%v: %s", err, stderr.String())
 	}
 	return stdout.String(), err
 }