diff options
Diffstat (limited to 'internal')
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) + } +} |