aboutsummaryrefslogtreecommitdiff
path: root/internal/encoding
diff options
context:
space:
mode:
Diffstat (limited to 'internal/encoding')
-rw-r--r--internal/encoding/ssh/filexfer/attrs.go326
-rw-r--r--internal/encoding/ssh/filexfer/attrs_test.go231
-rw-r--r--internal/encoding/ssh/filexfer/buffer.go293
-rw-r--r--internal/encoding/ssh/filexfer/extended_packets.go142
-rw-r--r--internal/encoding/ssh/filexfer/extended_packets_test.go240
-rw-r--r--internal/encoding/ssh/filexfer/extensions.go46
-rw-r--r--internal/encoding/ssh/filexfer/extensions_test.go49
-rw-r--r--internal/encoding/ssh/filexfer/filexfer.go54
-rw-r--r--internal/encoding/ssh/filexfer/fx.go147
-rw-r--r--internal/encoding/ssh/filexfer/fx_test.go102
-rw-r--r--internal/encoding/ssh/filexfer/fxp.go124
-rw-r--r--internal/encoding/ssh/filexfer/fxp_test.go84
-rw-r--r--internal/encoding/ssh/filexfer/handle_packets.go249
-rw-r--r--internal/encoding/ssh/filexfer/handle_packets_test.go282
-rw-r--r--internal/encoding/ssh/filexfer/init_packets.go99
-rw-r--r--internal/encoding/ssh/filexfer/init_packets_test.go114
-rw-r--r--internal/encoding/ssh/filexfer/open_packets.go89
-rw-r--r--internal/encoding/ssh/filexfer/open_packets_test.go107
-rw-r--r--internal/encoding/ssh/filexfer/openssh/fsync.go73
-rw-r--r--internal/encoding/ssh/filexfer/openssh/fsync_test.go62
-rw-r--r--internal/encoding/ssh/filexfer/openssh/hardlink.go79
-rw-r--r--internal/encoding/ssh/filexfer/openssh/hardlink_test.go69
-rw-r--r--internal/encoding/ssh/filexfer/openssh/openssh.go2
-rw-r--r--internal/encoding/ssh/filexfer/openssh/posix-rename.go79
-rw-r--r--internal/encoding/ssh/filexfer/openssh/posix-rename_test.go69
-rw-r--r--internal/encoding/ssh/filexfer/openssh/statvfs.go256
-rw-r--r--internal/encoding/ssh/filexfer/openssh/statvfs_test.go239
-rw-r--r--internal/encoding/ssh/filexfer/packets.go323
-rw-r--r--internal/encoding/ssh/filexfer/packets_test.go132
-rw-r--r--internal/encoding/ssh/filexfer/path_packets.go368
-rw-r--r--internal/encoding/ssh/filexfer/path_packets_test.go450
-rw-r--r--internal/encoding/ssh/filexfer/permissions.go114
-rw-r--r--internal/encoding/ssh/filexfer/response_packets.go243
-rw-r--r--internal/encoding/ssh/filexfer/response_packets_test.go296
34 files changed, 5632 insertions, 0 deletions
diff --git a/internal/encoding/ssh/filexfer/attrs.go b/internal/encoding/ssh/filexfer/attrs.go
new file mode 100644
index 0000000..1d4bb79
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/attrs.go
@@ -0,0 +1,326 @@
+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
+}
+
+// GetSize returns the Size field and a bool that is true if and only if the value is valid/defined.
+func (a *Attributes) GetSize() (size uint64, ok bool) {
+ return a.Size, a.Flags&AttrSize != 0
+}
+
+// SetSize is a convenience function that sets the Size field,
+// and marks the field as valid/defined in Flags.
+func (a *Attributes) SetSize(size uint64) {
+ a.Flags |= AttrSize
+ a.Size = size
+}
+
+// GetUIDGID returns the UID and GID fields and a bool that is true if and only if the values are valid/defined.
+func (a *Attributes) GetUIDGID() (uid, gid uint32, ok bool) {
+ return a.UID, a.GID, a.Flags&AttrUIDGID != 0
+}
+
+// SetUIDGID is a convenience function that sets the UID and GID fields,
+// and marks the fields as valid/defined in Flags.
+func (a *Attributes) SetUIDGID(uid, gid uint32) {
+ a.Flags |= AttrUIDGID
+ a.UID = uid
+ a.GID = gid
+}
+
+// GetPermissions returns the Permissions field and a bool that is true if and only if the value is valid/defined.
+func (a *Attributes) GetPermissions() (perms FileMode, ok bool) {
+ return a.Permissions, a.Flags&AttrPermissions != 0
+}
+
+// SetPermissions is a convenience function that sets the Permissions field,
+// and marks the field as valid/defined in Flags.
+func (a *Attributes) SetPermissions(perms FileMode) {
+ a.Flags |= AttrPermissions
+ a.Permissions = perms
+}
+
+// GetACModTime returns the ATime and MTime fields and a bool that is true if and only if the values are valid/defined.
+func (a *Attributes) GetACModTime() (atime, mtime uint32, ok bool) {
+ return a.ATime, a.MTime, a.Flags&AttrACModTime != 0
+ return a.ATime, a.MTime, a.Flags&AttrACModTime != 0
+}
+
+// SetACModTime is a convenience function that sets the ATime and MTime fields,
+// and marks the fields as valid/defined in Flags.
+func (a *Attributes) SetACModTime(atime, mtime uint32) {
+ a.Flags |= AttrACModTime
+ a.ATime = atime
+ a.MTime = mtime
+}
+
+// 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, 0, 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) {
+ flags, err := b.ConsumeUint32()
+ if err != nil {
+ return err
+ }
+
+ return a.XXX_UnmarshalByFlags(flags, b)
+}
+
+// XXX_UnmarshalByFlags uses the pre-existing a.Flags field to determine which fields to decode.
+// DO NOT USE THIS: it is an anti-corruption function to implement existing internal usage in pkg/sftp.
+// This function is not a part of any compatibility promise.
+func (a *Attributes) XXX_UnmarshalByFlags(flags uint32, b *Buffer) (err error) {
+ a.Flags = flags
+
+ // 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, 0, 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, 0, 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/attrs_test.go b/internal/encoding/ssh/filexfer/attrs_test.go
new file mode 100644
index 0000000..c03015c
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/attrs_test.go
@@ -0,0 +1,231 @@
+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, err := attr.MarshalBinary()
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if !bytes.Equal(buf, tt.encoded) {
+ t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, tt.encoded)
+ }
+
+ attr = Attributes{}
+
+ if err := attr.UnmarshalBinary(buf); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if attr.Flags != tt.flags {
+ t.Errorf("UnmarshalBinary(): Flags was %x, but wanted %x", attr.Flags, tt.flags)
+ }
+
+ if attr.Flags&AttrSize != 0 && attr.Size != size {
+ t.Errorf("UnmarshalBinary(): Size was %x, but wanted %x", attr.Size, size)
+ }
+
+ if attr.Flags&AttrUIDGID != 0 {
+ if attr.UID != uid {
+ t.Errorf("UnmarshalBinary(): UID was %x, but wanted %x", attr.UID, uid)
+ }
+
+ if attr.GID != gid {
+ t.Errorf("UnmarshalBinary(): GID was %x, but wanted %x", attr.GID, gid)
+ }
+ }
+
+ if attr.Flags&AttrPermissions != 0 && attr.Permissions != perms {
+ t.Errorf("UnmarshalBinary(): Permissions was %#v, but wanted %#v", attr.Permissions, perms)
+ }
+
+ if attr.Flags&AttrACModTime != 0 {
+ if attr.ATime != atime {
+ t.Errorf("UnmarshalBinary(): ATime was %x, but wanted %x", attr.ATime, atime)
+ }
+
+ if attr.MTime != mtime {
+ t.Errorf("UnmarshalBinary(): 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("UnmarshalBinary(): len(ExtendedAttributes) was %d, but wanted %d", count, 1)
+ }
+
+ if got := extAttrs[0]; got != extAttr {
+ t.Errorf("UnmarshalBinary(): 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, err := e.MarshalBinary()
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ 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 !bytes.Equal(buf, want) {
+ t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *e = NameEntry{}
+
+ if err := e.UnmarshalBinary(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/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..6b7b2ce
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extended_packets.go
@@ -0,0 +1,142 @@
+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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ExtendedPacket) Type() PacketType {
+ return PacketTypeExtended
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ExtendedReplyPacket) Type() PacketType {
+ return PacketTypeExtendedReply
+}
+
+// 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/extended_packets_test.go b/internal/encoding/ssh/filexfer/extended_packets_test.go
new file mode 100644
index 0000000..0860773
--- /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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ExtendedPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ExtendedPacket{
+ Data: new(testExtendedData),
+ }
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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 buf, ok := p.Data.(*testExtendedData); !ok {
+ t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf)
+
+ } else if buf.value != value {
+ t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", buf.value, value)
+ }
+
+ *p = ExtendedPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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 buf, ok := p.Data.(*Buffer); !ok {
+ t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf)
+
+ } else if !bytes.Equal(buf.b, wantBuffer) {
+ t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", buf.b, wantBuffer)
+ }
+}
+
+var _ Packet = &ExtendedReplyPacket{}
+
+func TestExtendedReplyNoData(t *testing.T) {
+ const (
+ id = 42
+ )
+
+ p := &ExtendedReplyPacket{}
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ExtendedReplyPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ExtendedReplyPacket{
+ Data: new(testExtendedData),
+ }
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if buf, ok := p.Data.(*testExtendedData); !ok {
+ t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf)
+
+ } else if buf.value != value {
+ t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", buf.value, value)
+ }
+
+ *p = ExtendedReplyPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ wantBuffer := []byte{0x27}
+
+ if buf, ok := p.Data.(*Buffer); !ok {
+ t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, buf)
+
+ } else if !bytes.Equal(buf.b, wantBuffer) {
+ t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", buf.b, wantBuffer)
+ }
+}
diff --git a/internal/encoding/ssh/filexfer/extensions.go b/internal/encoding/ssh/filexfer/extensions.go
new file mode 100644
index 0000000..11c0b99
--- /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, 0, 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/extensions_test.go b/internal/encoding/ssh/filexfer/extensions_test.go
new file mode 100644
index 0000000..453265b
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/extensions_test.go
@@ -0,0 +1,49 @@
+package filexfer
+
+import (
+ "bytes"
+ "testing"
+)
+
+func TestExtensionPair(t *testing.T) {
+ const (
+ name = "foo"
+ data = "1"
+ )
+
+ pair := &ExtensionPair{
+ Name: name,
+ Data: data,
+ }
+
+ buf, err := pair.MarshalBinary()
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 3,
+ 'f', 'o', 'o',
+ 0x00, 0x00, 0x00, 1,
+ '1',
+ }
+
+ if !bytes.Equal(buf, want) {
+ t.Errorf("ExtensionPair.MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *pair = ExtensionPair{}
+
+ if err := pair.UnmarshalBinary(buf); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if pair.Name != name {
+ t.Errorf("ExtensionPair.UnmarshalBinary(): 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/filexfer.go b/internal/encoding/ssh/filexfer/filexfer.go
new file mode 100644
index 0000000..1e5abf7
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/filexfer.go
@@ -0,0 +1,54 @@
+// Package filexfer implements the wire encoding for secsh-filexfer as described in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02
+package filexfer
+
+// PacketMarshaller narrowly defines packets that will only be transmitted.
+//
+// ExtendedPacket types will often only implement this interface,
+// since decoding the whole packet body of an ExtendedPacket can only be done dependent on the ExtendedRequest field.
+type PacketMarshaller 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)
+}
+
+// Packet defines the behavior of a full generic SFTP packet.
+//
+// InitPacket, and VersionPacket are not generic SFTP packets, and instead implement (Un)MarshalBinary.
+//
+// ExtendedPacket types should not iplement this interface,
+// since decoding the whole packet body of an ExtendedPacket can only be done dependent on the ExtendedRequest field.
+type Packet interface {
+ PacketMarshaller
+
+ // Type returns the SSH_FXP_xy value associated with the specific packet.
+ Type() PacketType
+
+ // 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.
+ //
+ // Implementations should not alias the given Buffer,
+ // instead they can consider prepopulating an internal buffer as a hint,
+ // and copying into that buffer if it has sufficient length.
+ 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..48f8698
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fx.go
@@ -0,0 +1,147 @@
+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 (s Status) Error() string {
+ return s.String()
+}
+
+// Is returns true if the target is the same Status code,
+// or target is a StatusPacket with the same Status code.
+func (s Status) Is(target error) bool {
+ if target, ok := target.(*StatusPacket); ok {
+ return target.StatusCode == s
+ }
+
+ return s == target
+}
+
+func (s Status) String() string {
+ switch s {
+ 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)", s)
+ }
+}
diff --git a/internal/encoding/ssh/filexfer/fx_test.go b/internal/encoding/ssh/filexfer/fx_test.go
new file mode 100644
index 0000000..3e8db1d
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/fx_test.go
@@ -0,0 +1,102 @@
+package filexfer
+
+import (
+ "bufio"
+ "errors"
+ "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)
+ }
+}
+
+func TestStatusIs(t *testing.T) {
+ status := StatusFailure
+
+ if !errors.Is(status, StatusFailure) {
+ t.Error("errors.Is(StatusFailure, StatusFailure) != true")
+ }
+ if !errors.Is(status, &StatusPacket{StatusCode: StatusFailure}) {
+ t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) != true")
+ }
+ if errors.Is(status, StatusOK) {
+ t.Error("errors.Is(StatusFailure, StatusFailure) == true")
+ }
+ if errors.Is(status, &StatusPacket{StatusCode: StatusOK}) {
+ t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) == true")
+ }
+}
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/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.go b/internal/encoding/ssh/filexfer/handle_packets.go
new file mode 100644
index 0000000..a142771
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/handle_packets.go
@@ -0,0 +1,249 @@
+package filexfer
+
+// ClosePacket defines the SSH_FXP_CLOSE packet.
+type ClosePacket struct {
+ Handle string
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ClosePacket) Type() PacketType {
+ return PacketTypeClose
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ReadPacket) Type() PacketType {
+ return PacketTypeRead
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *WritePacket) Type() PacketType {
+ return PacketTypeWrite
+}
+
+// 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.
+//
+// If p.Data is already populated, and of sufficient length to hold the data,
+// then this will copy the data into that byte slice.
+//
+// If p.Data has a length insufficient to hold the data,
+// then this will make a new slice of sufficient length, and copy the data into that.
+//
+// This means this _does not_ alias any of the data buffer that is passed in.
+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
+ }
+
+ data, err := buf.ConsumeByteSlice()
+ if err != nil {
+ return err
+ }
+
+ if len(p.Data) < len(data) {
+ p.Data = make([]byte, len(data))
+ }
+
+ n := copy(p.Data, data)
+ p.Data = p.Data[:n]
+ return nil
+}
+
+// FStatPacket defines the SSH_FXP_FSTAT packet.
+type FStatPacket struct {
+ Handle string
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *FStatPacket) Type() PacketType {
+ return PacketTypeFStat
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *FSetstatPacket) Type() PacketType {
+ return PacketTypeFSetstat
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ReadDirPacket) Type() PacketType {
+ return PacketTypeReadDir
+}
+
+// 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/handle_packets_test.go b/internal/encoding/ssh/filexfer/handle_packets_test.go
new file mode 100644
index 0000000..8cc394e
--- /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",
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ClosePacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ReadPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = WritePacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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",
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = FStatPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = FSetstatPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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",
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ReadDirPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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.go b/internal/encoding/ssh/filexfer/init_packets.go
new file mode 100644
index 0000000..b0bc6f5
--- /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(PacketTypeVersion))
+ 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/init_packets_test.go b/internal/encoding/ssh/filexfer/init_packets_test.go
new file mode 100644
index 0000000..e7605f9
--- /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",
+ },
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *p = InitPacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalBinary(buf[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",
+ },
+ },
+ }
+
+ buf, err := p.MarshalBinary()
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 17,
+ 2,
+ 0x00, 0x00, 0x00, version,
+ 0x00, 0x00, 0x00, 3, 'f', 'o', 'o',
+ 0x00, 0x00, 0x00, 1, '1',
+ }
+
+ if !bytes.Equal(buf, want) {
+ t.Fatalf("MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *p = VersionPacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalBinary(buf[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.go b/internal/encoding/ssh/filexfer/open_packets.go
new file mode 100644
index 0000000..1358711
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/open_packets.go
@@ -0,0 +1,89 @@
+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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *OpenPacket) Type() PacketType {
+ return PacketTypeOpen
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *OpenDirPacket) Type() PacketType {
+ return PacketTypeOpenDir
+}
+
+// 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/open_packets_test.go b/internal/encoding/ssh/filexfer/open_packets_test.go
new file mode 100644
index 0000000..560c8b4
--- /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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = OpenPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = OpenDirPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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/openssh/fsync.go b/internal/encoding/ssh/filexfer/openssh/fsync.go
new file mode 100644
index 0000000..7ecfb0c
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/fsync.go
@@ -0,0 +1,73 @@
+package openssh
+
+import (
+ sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+const extensionFSync = "fsync@openssh.com"
+
+// RegisterExtensionFSync registers the "fsync@openssh.com" extended packet with the encoding/ssh/filexfer package.
+func RegisterExtensionFSync() {
+ sshfx.RegisterExtendedPacketType(extensionFSync, func() sshfx.ExtendedData {
+ return new(FSyncExtendedPacket)
+ })
+}
+
+// ExtensionFSync returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket.
+func ExtensionFSync() *sshfx.ExtensionPair {
+ return &sshfx.ExtensionPair{
+ Name: extensionFSync,
+ Data: "1",
+ }
+}
+
+// FSyncExtendedPacket defines the fsync@openssh.com extend packet.
+type FSyncExtendedPacket struct {
+ Handle string
+}
+
+// Type returns the SSH_FXP_EXTENDED packet type.
+func (ep *FSyncExtendedPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtended
+}
+
+// MarshalPacket returns ep as a two-part binary encoding of the full extended packet.
+func (ep *FSyncExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) {
+ p := &sshfx.ExtendedPacket{
+ ExtendedRequest: extensionFSync,
+
+ Data: ep,
+ }
+ return p.MarshalPacket(reqid, b)
+}
+
+// MarshalInto encodes ep into the binary encoding of the fsync@openssh.com extended packet-specific data.
+func (ep *FSyncExtendedPacket) MarshalInto(buf *sshfx.Buffer) {
+ buf.AppendString(ep.Handle)
+}
+
+// MarshalBinary encodes ep into the binary encoding of the fsync@openssh.com extended packet-specific data.
+//
+// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet.
+func (ep *FSyncExtendedPacket) MarshalBinary() ([]byte, error) {
+ // string(handle)
+ size := 4 + len(ep.Handle)
+
+ buf := sshfx.NewBuffer(make([]byte, 0, size))
+ ep.MarshalInto(buf)
+ return buf.Bytes(), nil
+}
+
+// UnmarshalFrom decodes the fsync@openssh.com extended packet-specific data from buf.
+func (ep *FSyncExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) {
+ if ep.Handle, err = buf.ConsumeString(); err != nil {
+ return err
+ }
+
+ return nil
+}
+
+// UnmarshalBinary decodes the fsync@openssh.com extended packet-specific data into ep.
+func (ep *FSyncExtendedPacket) UnmarshalBinary(data []byte) (err error) {
+ return ep.UnmarshalFrom(sshfx.NewBuffer(data))
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/fsync_test.go b/internal/encoding/ssh/filexfer/openssh/fsync_test.go
new file mode 100644
index 0000000..f9e878f
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/fsync_test.go
@@ -0,0 +1,62 @@
+package openssh
+
+import (
+ "bytes"
+ "testing"
+
+ sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+var _ sshfx.PacketMarshaller = &FSyncExtendedPacket{}
+
+func init() {
+ RegisterExtensionFSync()
+}
+
+func TestFSyncExtendedPacket(t *testing.T) {
+ const (
+ id = 42
+ handle = "somehandle"
+ )
+
+ ep := &FSyncExtendedPacket{
+ Handle: handle,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 40,
+ 200,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 17, 'f', 's', 'y', 'n', 'c', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
+ 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e',
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ var p sshfx.ExtendedPacket
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.ExtendedRequest != extensionFSync {
+ t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionFSync)
+ }
+
+ ep, ok := p.Data.(*FSyncExtendedPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *FSyncExtendedPacket", p.Data)
+ }
+
+ if ep.Handle != handle {
+ t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", ep.Handle, handle)
+ }
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/hardlink.go b/internal/encoding/ssh/filexfer/openssh/hardlink.go
new file mode 100644
index 0000000..17c3499
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/hardlink.go
@@ -0,0 +1,79 @@
+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
+}
+
+// Type returns the SSH_FXP_EXTENDED packet type.
+func (ep *HardlinkExtendedPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtended
+}
+
+// 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, 0, 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/hardlink_test.go b/internal/encoding/ssh/filexfer/openssh/hardlink_test.go
new file mode 100644
index 0000000..5d3be06
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/hardlink_test.go
@@ -0,0 +1,69 @@
+package openssh
+
+import (
+ "bytes"
+ "testing"
+
+ sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+var _ sshfx.PacketMarshaller = &HardlinkExtendedPacket{}
+
+func init() {
+ RegisterExtensionHardlink()
+}
+
+func TestHardlinkExtendedPacket(t *testing.T) {
+ const (
+ id = 42
+ oldpath = "/foo"
+ newpath = "/bar"
+ )
+
+ ep := &HardlinkExtendedPacket{
+ OldPath: oldpath,
+ NewPath: newpath,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 45,
+ 200,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 20, 'h', 'a', 'r', 'd', 'l', 'i', 'n', 'k', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
+ 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+ 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r',
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ var p sshfx.ExtendedPacket
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.ExtendedRequest != extensionHardlink {
+ t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionHardlink)
+ }
+
+ ep, ok := p.Data.(*HardlinkExtendedPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *HardlinkExtendedPacket", p.Data)
+ }
+
+ if ep.OldPath != oldpath {
+ t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", ep.OldPath, oldpath)
+ }
+
+ if ep.NewPath != newpath {
+ t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", ep.NewPath, newpath)
+ }
+}
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..a3d3de5
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/posix-rename.go
@@ -0,0 +1,79 @@
+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
+}
+
+// Type returns the SSH_FXP_EXTENDED packet type.
+func (ep *PosixRenameExtendedPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtended
+}
+
+// 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, 0, 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/posix-rename_test.go b/internal/encoding/ssh/filexfer/openssh/posix-rename_test.go
new file mode 100644
index 0000000..6bdb10d
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/posix-rename_test.go
@@ -0,0 +1,69 @@
+package openssh
+
+import (
+ "bytes"
+ "testing"
+
+ sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+var _ sshfx.PacketMarshaller = &PosixRenameExtendedPacket{}
+
+func init() {
+ RegisterExtensionPosixRename()
+}
+
+func TestPosixRenameExtendedPacket(t *testing.T) {
+ const (
+ id = 42
+ oldpath = "/foo"
+ newpath = "/bar"
+ )
+
+ ep := &PosixRenameExtendedPacket{
+ OldPath: oldpath,
+ NewPath: newpath,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 49,
+ 200,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 24, 'p', 'o', 's', 'i', 'x', '-', 'r', 'e', 'n', 'a', 'm', 'e', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
+ 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+ 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r',
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ var p sshfx.ExtendedPacket
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.ExtendedRequest != extensionPosixRename {
+ t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionPosixRename)
+ }
+
+ ep, ok := p.Data.(*PosixRenameExtendedPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *PosixRenameExtendedPacket", p.Data)
+ }
+
+ if ep.OldPath != oldpath {
+ t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", ep.OldPath, oldpath)
+ }
+
+ if ep.NewPath != newpath {
+ t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", ep.NewPath, newpath)
+ }
+}
diff --git a/internal/encoding/ssh/filexfer/openssh/statvfs.go b/internal/encoding/ssh/filexfer/openssh/statvfs.go
new file mode 100644
index 0000000..3e9015f
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/statvfs.go
@@ -0,0 +1,256 @@
+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
+}
+
+// Type returns the SSH_FXP_EXTENDED packet type.
+func (ep *StatVFSExtendedPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtended
+}
+
+// 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, 0, 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
+}
+
+// Type returns the SSH_FXP_EXTENDED packet type.
+func (ep *FStatVFSExtendedPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtended
+}
+
+// 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, 0, 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 */
+}
+
+// Type returns the SSH_FXP_EXTENDED_REPLY packet type.
+func (ep *StatVFSExtendedReplyPacket) Type() sshfx.PacketType {
+ return sshfx.PacketTypeExtendedReply
+}
+
+// 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)
+}
+
+// UnmarshalPacketBody returns ep as a two-part binary encoding of the full extended reply packet.
+func (ep *StatVFSExtendedReplyPacket) UnmarshalPacketBody(buf *sshfx.Buffer) (err error) {
+ p := &sshfx.ExtendedReplyPacket{
+ Data: ep,
+ }
+ return p.UnmarshalPacketBody(buf)
+}
+
+// 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, 0, 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/openssh/statvfs_test.go b/internal/encoding/ssh/filexfer/openssh/statvfs_test.go
new file mode 100644
index 0000000..014aa63
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/openssh/statvfs_test.go
@@ -0,0 +1,239 @@
+package openssh
+
+import (
+ "bytes"
+ "testing"
+
+ sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer"
+)
+
+var _ sshfx.PacketMarshaller = &StatVFSExtendedPacket{}
+
+func init() {
+ RegisterExtensionStatVFS()
+}
+
+func TestStatVFSExtendedPacket(t *testing.T) {
+ const (
+ id = 42
+ path = "/foo"
+ )
+
+ ep := &StatVFSExtendedPacket{
+ Path: path,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 36,
+ 200,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 19, 's', 't', 'a', 't', 'v', 'f', 's', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
+ 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ var p sshfx.ExtendedPacket
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.ExtendedRequest != extensionStatVFS {
+ t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionStatVFS)
+ }
+
+ ep, ok := p.Data.(*StatVFSExtendedPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *StatVFSExtendedPacket", p.Data)
+ }
+
+ if ep.Path != path {
+ t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", ep.Path, path)
+ }
+}
+
+var _ sshfx.PacketMarshaller = &FStatVFSExtendedPacket{}
+
+func init() {
+ RegisterExtensionFStatVFS()
+}
+
+func TestFStatVFSExtendedPacket(t *testing.T) {
+ const (
+ id = 42
+ path = "/foo"
+ )
+
+ ep := &FStatVFSExtendedPacket{
+ Path: path,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 37,
+ 200,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 20, 'f', 's', 't', 'a', 't', 'v', 'f', 's', '@', 'o', 'p', 'e', 'n', 's', 's', 'h', '.', 'c', 'o', 'm',
+ 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o',
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ var p sshfx.ExtendedPacket
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.ExtendedRequest != extensionFStatVFS {
+ t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extensionFStatVFS)
+ }
+
+ ep, ok := p.Data.(*FStatVFSExtendedPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *FStatVFSExtendedPacket", p.Data)
+ }
+
+ if ep.Path != path {
+ t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", ep.Path, path)
+ }
+}
+
+var _ sshfx.Packet = &StatVFSExtendedReplyPacket{}
+
+func TestStatVFSExtendedReplyPacket(t *testing.T) {
+ const (
+ id = 42
+ path = "/foo"
+ )
+
+ const (
+ BlockSize = uint64(iota + 13)
+ FragmentSize
+ Blocks
+ BlocksFree
+ BlocksAvail
+ Files
+ FilesFree
+ FilesAvail
+ FilesystemID
+ MountFlags
+ MaxNameLength
+ )
+
+ ep := &StatVFSExtendedReplyPacket{
+ BlockSize: BlockSize,
+ FragmentSize: FragmentSize,
+ Blocks: Blocks,
+ BlocksFree: BlocksFree,
+ BlocksAvail: BlocksAvail,
+ Files: Files,
+ FilesFree: FilesFree,
+ FilesAvail: FilesAvail,
+ FilesystemID: FilesystemID,
+ MountFlags: MountFlags,
+ MaxNameLength: MaxNameLength,
+ }
+
+ data, err := sshfx.ComposePacket(ep.MarshalPacket(id, nil))
+ if err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ want := []byte{
+ 0x00, 0x00, 0x00, 93,
+ 201,
+ 0x00, 0x00, 0x00, 42,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 13,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 14,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 15,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 16,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 17,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 18,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 19,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 20,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 21,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 22,
+ 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 23,
+ }
+
+ if !bytes.Equal(data, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", data, want)
+ }
+
+ *ep = StatVFSExtendedReplyPacket{}
+
+ p := sshfx.ExtendedReplyPacket{
+ Data: ep,
+ }
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(sshfx.NewBuffer(data[9:])); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ ep, ok := p.Data.(*StatVFSExtendedReplyPacket)
+ if !ok {
+ t.Fatalf("UnmarshaledPacketBody(): Data was type %T, but expected *StatVFSExtendedReplyPacket", p.Data)
+ }
+
+ if ep.BlockSize != BlockSize {
+ t.Errorf("UnmarshalPacketBody(): BlockSize was %d, but expected %d", ep.BlockSize, BlockSize)
+ }
+
+ if ep.FragmentSize != FragmentSize {
+ t.Errorf("UnmarshalPacketBody(): FragmentSize was %d, but expected %d", ep.FragmentSize, FragmentSize)
+ }
+
+ if ep.Blocks != Blocks {
+ t.Errorf("UnmarshalPacketBody(): Blocks was %d, but expected %d", ep.Blocks, Blocks)
+ }
+
+ if ep.BlocksFree != BlocksFree {
+ t.Errorf("UnmarshalPacketBody(): BlocksFree was %d, but expected %d", ep.BlocksFree, BlocksFree)
+ }
+
+ if ep.BlocksAvail != BlocksAvail {
+ t.Errorf("UnmarshalPacketBody(): BlocksAvail was %d, but expected %d", ep.BlocksAvail, BlocksAvail)
+ }
+
+ if ep.Files != Files {
+ t.Errorf("UnmarshalPacketBody(): Files was %d, but expected %d", ep.Files, Files)
+ }
+
+ if ep.FilesFree != FilesFree {
+ t.Errorf("UnmarshalPacketBody(): FilesFree was %d, but expected %d", ep.FilesFree, FilesFree)
+ }
+
+ if ep.FilesAvail != FilesAvail {
+ t.Errorf("UnmarshalPacketBody(): FilesAvail was %d, but expected %d", ep.FilesAvail, FilesAvail)
+ }
+
+ if ep.FilesystemID != FilesystemID {
+ t.Errorf("UnmarshalPacketBody(): FilesystemID was %d, but expected %d", ep.FilesystemID, FilesystemID)
+ }
+
+ if ep.MountFlags != MountFlags {
+ t.Errorf("UnmarshalPacketBody(): MountFlags was %d, but expected %d", ep.MountFlags, MountFlags)
+ }
+
+ if ep.MaxNameLength != MaxNameLength {
+ t.Errorf("UnmarshalPacketBody(): MaxNameLength was %d, but expected %d", ep.MaxNameLength, MaxNameLength)
+ }
+}
diff --git a/internal/encoding/ssh/filexfer/packets.go b/internal/encoding/ssh/filexfer/packets.go
new file mode 100644
index 0000000..3f24e9c
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/packets.go
@@ -0,0 +1,323 @@
+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 {
+ PacketType PacketType
+ RequestID uint32
+
+ Data Buffer
+}
+
+// Type returns the Type field defining the SSH_FXP_xy type for this packet.
+func (p *RawPacket) Type() PacketType {
+ return p.PacketType
+}
+
+// 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.PacketType, 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.PacketType = 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 occasional 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
+}
+
+// Type returns the SSH_FXP_xy value associated with the underlying packet.
+func (p *RequestPacket) Type() PacketType {
+ return p.Request.Type()
+}
+
+// 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 occasional 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/packets_test.go b/internal/encoding/ssh/filexfer/packets_test.go
new file mode 100644
index 0000000..1600920
--- /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{
+ PacketType: PacketTypeStatus,
+ RequestID: id,
+ Data: Buffer{
+ b: []byte{
+ 0x00, 0x00, 0x00, 0x01,
+ 0x00, 0x00, 0x00, 0x03, 'e', 'o', 'f',
+ 0x00, 0x00, 0x00, 0x02, 'e', 'n',
+ },
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Errorf("RawPacket.MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RawPacket{}
+
+ if err := p.ReadFrom(bytes.NewReader(buf), nil, DefaultMaxPacketLength); err != nil {
+ t.Fatal("unexpected error:", err)
+ }
+
+ if p.PacketType != PacketTypeStatus {
+ t.Errorf("RawPacket.UnmarshalBinary(): Type was %v, but expected %v", p.PacketType, 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(): StatusCode was %v, but expected %v", resp.StatusCode, StatusEOF)
+ }
+
+ if resp.ErrorMessage != errMsg {
+ t.Errorf("UnmarshalPacketBody(): ErrorMessage was %q, but expected %q", resp.ErrorMessage, errMsg)
+ }
+
+ if resp.LanguageTag != langTag {
+ t.Errorf("UnmarshalPacketBody(): 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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Errorf("RequestPacket.MarshalBinary() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RequestPacket{}
+
+ if err := p.ReadFrom(bytes.NewReader(buf), 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.go b/internal/encoding/ssh/filexfer/path_packets.go
new file mode 100644
index 0000000..e6f692d
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/path_packets.go
@@ -0,0 +1,368 @@
+package filexfer
+
+// LStatPacket defines the SSH_FXP_LSTAT packet.
+type LStatPacket struct {
+ Path string
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *LStatPacket) Type() PacketType {
+ return PacketTypeLStat
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *SetstatPacket) Type() PacketType {
+ return PacketTypeSetstat
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *RemovePacket) Type() PacketType {
+ return PacketTypeRemove
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *MkdirPacket) Type() PacketType {
+ return PacketTypeMkdir
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *RmdirPacket) Type() PacketType {
+ return PacketTypeRmdir
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *RealPathPacket) Type() PacketType {
+ return PacketTypeRealPath
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *StatPacket) Type() PacketType {
+ return PacketTypeStat
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *RenamePacket) Type() PacketType {
+ return PacketTypeRename
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *ReadLinkPacket) Type() PacketType {
+ return PacketTypeReadLink
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *SymlinkPacket) Type() PacketType {
+ return PacketTypeSymlink
+}
+
+// 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/path_packets_test.go b/internal/encoding/ssh/filexfer/path_packets_test.go
new file mode 100644
index 0000000..4cff582
--- /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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = LStatPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = SetstatPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RemovePacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = MkdirPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RmdirPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RealPathPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = StatPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = RenamePacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = ReadLinkPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = SymlinkPacket{}
+
+ // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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/permissions.go b/internal/encoding/ssh/filexfer/permissions.go
new file mode 100644
index 0000000..2fe63d5
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/permissions.go
@@ -0,0 +1,114 @@
+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] = '-'
+ }
+ }
+
+ if m&ModeSetUID != 0 {
+ if buf[3] == 'x' {
+ buf[3] = 's'
+ } else {
+ buf[3] = 'S'
+ }
+ }
+
+ if m&ModeSetGID != 0 {
+ if buf[6] == 'x' {
+ buf[6] = 's'
+ } else {
+ buf[6] = 'S'
+ }
+ }
+
+ if m&ModeSticky != 0 {
+ if buf[9] == 'x' {
+ buf[9] = 't'
+ } else {
+ buf[9] = 'T'
+ }
+ }
+
+ 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..7a9b3ea
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/response_packets.go
@@ -0,0 +1,243 @@
+package filexfer
+
+import (
+ "fmt"
+)
+
+// 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
+}
+
+// Error makes StatusPacket an error type.
+func (p *StatusPacket) Error() string {
+ if p.ErrorMessage == "" {
+ return "sftp: " + p.StatusCode.String()
+ }
+
+ return fmt.Sprintf("sftp: %q (%s)", p.ErrorMessage, p.StatusCode)
+}
+
+// Is returns true if target is a StatusPacket with the same StatusCode,
+// or target is a Status code which is the same as SatusCode.
+func (p *StatusPacket) Is(target error) bool {
+ if target, ok := target.(*StatusPacket); ok {
+ return p.StatusCode == target.StatusCode
+ }
+
+ return p.StatusCode == target
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *StatusPacket) Type() PacketType {
+ return PacketTypeStatus
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *HandlePacket) Type() PacketType {
+ return PacketTypeHandle
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *DataPacket) Type() PacketType {
+ return PacketTypeData
+}
+
+// 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.
+//
+// If p.Data is already populated, and of sufficient length to hold the data,
+// then this will copy the data into that byte slice.
+//
+// If p.Data has a length insufficient to hold the data,
+// then this will make a new slice of sufficient length, and copy the data into that.
+//
+// This means this _does not_ alias any of the data buffer that is passed in.
+func (p *DataPacket) UnmarshalPacketBody(buf *Buffer) (err error) {
+ data, err := buf.ConsumeByteSlice()
+ if err != nil {
+ return err
+ }
+
+ if len(p.Data) < len(data) {
+ p.Data = make([]byte, len(data))
+ }
+
+ n := copy(p.Data, data)
+ p.Data = p.Data[:n]
+ return nil
+}
+
+// NamePacket defines the SSH_FXP_NAME packet.
+type NamePacket struct {
+ Entries []*NameEntry
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *NamePacket) Type() PacketType {
+ return PacketTypeName
+}
+
+// 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
+}
+
+// Type returns the SSH_FXP_xy value associated with this packet type.
+func (p *AttrsPacket) Type() PacketType {
+ return PacketTypeAttrs
+}
+
+// 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/internal/encoding/ssh/filexfer/response_packets_test.go b/internal/encoding/ssh/filexfer/response_packets_test.go
new file mode 100644
index 0000000..9468665
--- /dev/null
+++ b/internal/encoding/ssh/filexfer/response_packets_test.go
@@ -0,0 +1,296 @@
+package filexfer
+
+import (
+ "bytes"
+ "errors"
+ "testing"
+)
+
+func TestStatusPacketIs(t *testing.T) {
+ status := &StatusPacket{
+ StatusCode: StatusFailure,
+ ErrorMessage: "error message",
+ LanguageTag: "language tag",
+ }
+
+ if !errors.Is(status, StatusFailure) {
+ t.Error("errors.Is(StatusFailure, StatusFailure) != true")
+ }
+ if !errors.Is(status, &StatusPacket{StatusCode: StatusFailure}) {
+ t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) != true")
+ }
+ if errors.Is(status, StatusOK) {
+ t.Error("errors.Is(StatusFailure, StatusFailure) == true")
+ }
+ if errors.Is(status, &StatusPacket{StatusCode: StatusOK}) {
+ t.Error("errors.Is(StatusFailure, StatusPacket{StatusFailure}) == true")
+ }
+}
+
+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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = StatusPacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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",
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = HandlePacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = DataPacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ },
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = NamePacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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,
+ },
+ }
+
+ buf, 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(buf, want) {
+ t.Fatalf("MarshalPacket() = %X, but wanted %X", buf, want)
+ }
+
+ *p = AttrsPacket{}
+
+ // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed.
+ if err := p.UnmarshalPacketBody(NewBuffer(buf[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)
+ }
+}