diff --git a/internal/encoding/ssh/filexfer/attrs.go b/internal/encoding/ssh/filexfer/attrs.go new file mode 100644 index 00000000..5ce942e7 --- /dev/null +++ b/internal/encoding/ssh/filexfer/attrs.go @@ -0,0 +1,265 @@ +package filexfer + +// Attributes related flags. +const ( + AttrSize = 1 << iota // SSH_FILEXFER_ATTR_SIZE + AttrUIDGID // SSH_FILEXFER_ATTR_UIDGID + AttrPermissions // SSH_FILEXFER_ATTR_PERMISSIONS + AttrACModTime // SSH_FILEXFER_ACMODTIME + + AttrExtended = 1 << 31 // SSH_FILEXFER_ATTR_EXTENDED +) + +// Attributes defines the file attributes type defined in draft-ietf-secsh-filexfer-02 +// +// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-5 +type Attributes struct { + Flags uint32 + + // AttrSize + Size uint64 + + // AttrUIDGID + UID uint32 + GID uint32 + + // AttrPermissions + Permissions FileMode + + // AttrACmodTime + ATime uint32 + MTime uint32 + + // AttrExtended + ExtendedAttributes []ExtendedAttribute +} + +// Len returns the number of bytes a would marshal into. +func (a *Attributes) Len() int { + length := 4 + + if a.Flags&AttrSize != 0 { + length += 8 + } + + if a.Flags&AttrUIDGID != 0 { + length += 4 + 4 + } + + if a.Flags&AttrPermissions != 0 { + length += 4 + } + + if a.Flags&AttrACModTime != 0 { + length += 4 + 4 + } + + if a.Flags&AttrExtended != 0 { + length += 4 + + for _, ext := range a.ExtendedAttributes { + length += ext.Len() + } + } + + return length +} + +// MarshalInto marshals e onto the end of the given Buffer. +func (a *Attributes) MarshalInto(b *Buffer) { + b.AppendUint32(a.Flags) + + if a.Flags&AttrSize != 0 { + b.AppendUint64(a.Size) + } + + if a.Flags&AttrUIDGID != 0 { + b.AppendUint32(a.UID) + b.AppendUint32(a.GID) + } + + if a.Flags&AttrPermissions != 0 { + b.AppendUint32(uint32(a.Permissions)) + } + + if a.Flags&AttrACModTime != 0 { + b.AppendUint32(a.ATime) + b.AppendUint32(a.MTime) + } + + if a.Flags&AttrExtended != 0 { + b.AppendUint32(uint32(len(a.ExtendedAttributes))) + + for _, ext := range a.ExtendedAttributes { + ext.MarshalInto(b) + } + } +} + +// MarshalBinary returns a as the binary encoding of a. +func (a *Attributes) MarshalBinary() ([]byte, error) { + buf := NewBuffer(make([]byte, a.Len())) + a.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom unmarshals an Attributes from the given Buffer into e. +// +// NOTE: The values of fields not covered in the a.Flags are explicitly undefined. +func (a *Attributes) UnmarshalFrom(b *Buffer) (err error) { + if a.Flags, err = b.ConsumeUint32(); err != nil { + return err + } + + // Short-circuit dummy attributes. + if a.Flags == 0 { + return nil + } + + if a.Flags&AttrSize != 0 { + if a.Size, err = b.ConsumeUint64(); err != nil { + return err + } + } + + if a.Flags&AttrUIDGID != 0 { + if a.UID, err = b.ConsumeUint32(); err != nil { + return err + } + + if a.GID, err = b.ConsumeUint32(); err != nil { + return err + } + } + + if a.Flags&AttrPermissions != 0 { + m, err := b.ConsumeUint32() + if err != nil { + return err + } + + a.Permissions = FileMode(m) + } + + if a.Flags&AttrACModTime != 0 { + if a.ATime, err = b.ConsumeUint32(); err != nil { + return err + } + + if a.MTime, err = b.ConsumeUint32(); err != nil { + return err + } + } + + if a.Flags&AttrExtended != 0 { + count, err := b.ConsumeUint32() + if err != nil { + return err + } + + a.ExtendedAttributes = make([]ExtendedAttribute, count) + for i := range a.ExtendedAttributes { + a.ExtendedAttributes[i].UnmarshalFrom(b) + } + } + + return nil +} + +// UnmarshalBinary decodes the binary encoding of Attributes into e. +func (a *Attributes) UnmarshalBinary(data []byte) error { + return a.UnmarshalFrom(NewBuffer(data)) +} + +// ExtendedAttribute defines the extended file attribute type defined in draft-ietf-secsh-filexfer-02 +// +// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-5 +type ExtendedAttribute struct { + Type string + Data string +} + +// Len returns the number of bytes e would marshal into. +func (e *ExtendedAttribute) Len() int { + return 4 + len(e.Type) + 4 + len(e.Data) +} + +// MarshalInto marshals e onto the end of the given Buffer. +func (e *ExtendedAttribute) MarshalInto(b *Buffer) { + b.AppendString(e.Type) + b.AppendString(e.Data) +} + +// MarshalBinary returns e as the binary encoding of e. +func (e *ExtendedAttribute) MarshalBinary() ([]byte, error) { + buf := NewBuffer(make([]byte, e.Len())) + e.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom unmarshals an ExtendedAattribute from the given Buffer into e. +func (e *ExtendedAttribute) UnmarshalFrom(b *Buffer) (err error) { + if e.Type, err = b.ConsumeString(); err != nil { + return err + } + + if e.Data, err = b.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the binary encoding of ExtendedAttribute into e. +func (e *ExtendedAttribute) UnmarshalBinary(data []byte) error { + return e.UnmarshalFrom(NewBuffer(data)) +} + +// NameEntry implements the SSH_FXP_NAME repeated data type from draft-ietf-secsh-filexfer-02 +// +// This type is incompatible with versions 4 or higher. +type NameEntry struct { + Filename string + Longname string + Attrs Attributes +} + +// Len returns the number of bytes e would marshal into. +func (e *NameEntry) Len() int { + return 4 + len(e.Filename) + 4 + len(e.Longname) + e.Attrs.Len() +} + +// MarshalInto marshals e onto the end of the given Buffer. +func (e *NameEntry) MarshalInto(b *Buffer) { + b.AppendString(e.Filename) + b.AppendString(e.Longname) + + e.Attrs.MarshalInto(b) +} + +// MarshalBinary returns e as the binary encoding of e. +func (e *NameEntry) MarshalBinary() ([]byte, error) { + buf := NewBuffer(make([]byte, e.Len())) + e.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom unmarshals an NameEntry from the given Buffer into e. +// +// NOTE: The values of fields not covered in the a.Flags are explicitly undefined. +func (e *NameEntry) UnmarshalFrom(b *Buffer) (err error) { + if e.Filename, err = b.ConsumeString(); err != nil { + return err + } + + if e.Longname, err = b.ConsumeString(); err != nil { + return err + } + + return e.Attrs.UnmarshalFrom(b) +} + +// UnmarshalBinary decodes the binary encoding of NameEntry into e. +func (e *NameEntry) UnmarshalBinary(data []byte) error { + return e.UnmarshalFrom(NewBuffer(data)) +} diff --git a/internal/encoding/ssh/filexfer/attrs_test.go b/internal/encoding/ssh/filexfer/attrs_test.go new file mode 100644 index 00000000..a4504a48 --- /dev/null +++ b/internal/encoding/ssh/filexfer/attrs_test.go @@ -0,0 +1,227 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +func TestAttributes(t *testing.T) { + const ( + size = 0x123456789ABCDEF0 + uid = 1000 + gid = 100 + perms = 0x87654321 + atime = 0x2A2B2C2D + mtime = 0x42434445 + ) + + extAttr := ExtendedAttribute{ + Type: "foo", + Data: "bar", + } + + attr := &Attributes{ + Size: size, + UID: uid, + GID: gid, + Permissions: perms, + ATime: atime, + MTime: mtime, + ExtendedAttributes: []ExtendedAttribute{ + extAttr, + }, + } + + type test struct { + name string + flags uint32 + encoded []byte + } + + tests := []test{ + { + name: "empty", + encoded: []byte{ + 0x00, 0x00, 0x00, 0x00, + }, + }, + { + name: "size", + flags: AttrSize, + encoded: []byte{ + 0x00, 0x00, 0x00, 0x01, + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, + }, + }, + { + name: "uidgid", + flags: AttrUIDGID, + encoded: []byte{ + 0x00, 0x00, 0x00, 0x02, + 0x00, 0x00, 0x03, 0xE8, + 0x00, 0x00, 0x00, 100, + }, + }, + { + name: "permissions", + flags: AttrPermissions, + encoded: []byte{ + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + }, + }, + { + name: "acmodtime", + flags: AttrACModTime, + encoded: []byte{ + 0x00, 0x00, 0x00, 0x08, + 0x2A, 0x2B, 0x2C, 0x2D, + 0x42, 0x43, 0x44, 0x45, + }, + }, + { + name: "extended", + flags: AttrExtended, + encoded: []byte{ + 0x80, 0x00, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', + 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', + }, + }, + { + name: "size uidgid permisssions acmodtime extended", + flags: AttrSize | AttrUIDGID | AttrPermissions | AttrACModTime | AttrExtended, + encoded: []byte{ + 0x80, 0x00, 0x00, 0x0F, + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, + 0x00, 0x00, 0x03, 0xE8, + 0x00, 0x00, 0x00, 100, + 0x87, 0x65, 0x43, 0x21, + 0x2A, 0x2B, 0x2C, 0x2D, + 0x42, 0x43, 0x44, 0x45, + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', + 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', + }, + }, + } + + for _, tt := range tests { + attr := *attr + + t.Run(tt.name, func(t *testing.T) { + attr.Flags = tt.flags + + buf := new(Buffer) + attr.MarshalInto(buf) + + if got, want := buf.Bytes(), tt.encoded; !bytes.Equal(got, want) { + t.Fatalf("MarshalInto() = %X, but wanted %X", got, want) + } + + attr = Attributes{} + + if err := attr.UnmarshalFrom(buf); err != nil { + t.Fatal("unexpected error:", err) + } + + if attr.Flags != tt.flags { + t.Errorf("UnmarshalFrom(): Flags was %x, but wanted %x", attr.Flags, tt.flags) + } + + if attr.Flags&AttrSize != 0 && attr.Size != size { + t.Errorf("UnmarshalFrom(): Size was %x, but wanted %x", attr.Size, size) + } + + if attr.Flags&AttrUIDGID != 0 { + if attr.UID != uid { + t.Errorf("UnmarshalFrom(): UID was %x, but wanted %x", attr.UID, uid) + } + + if attr.GID != gid { + t.Errorf("UnmarshalFrom(): GID was %x, but wanted %x", attr.GID, gid) + } + } + + if attr.Flags&AttrPermissions != 0 && attr.Permissions != perms { + t.Errorf("UnmarshalFrom(): Permissions was %#v, but wanted %#v", attr.Permissions, perms) + } + + if attr.Flags&AttrACModTime != 0 { + if attr.ATime != atime { + t.Errorf("UnmarshalFrom(): ATime was %x, but wanted %x", attr.ATime, atime) + } + + if attr.MTime != mtime { + t.Errorf("UnmarshalFrom(): MTime was %x, but wanted %x", attr.MTime, mtime) + } + } + + if attr.Flags&AttrExtended != 0 { + extAttrs := attr.ExtendedAttributes + + if count := len(extAttrs); count != 1 { + t.Fatalf("UnmarshalFrom(): len(ExtendedAttributes) was %d, but wanted %d", count, 1) + } + + if got := extAttrs[0]; got != extAttr { + t.Errorf("UnmarshalFrom(): ExtendedAttributes[0] was %#v, but wanted %#v", got, extAttr) + } + } + }) + } +} + +func TestNameEntry(t *testing.T) { + const ( + filename = "foo" + longname = "bar" + perms = 0x87654321 + ) + + e := &NameEntry{ + Filename: filename, + Longname: longname, + Attrs: Attributes{ + Flags: AttrPermissions, + Permissions: perms, + }, + } + + buf := new(Buffer) + e.MarshalInto(buf) + + want := []byte{ + 0x00, 0x00, 0x00, 0x03, 'f', 'o', 'o', + 0x00, 0x00, 0x00, 0x03, 'b', 'a', 'r', + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + } + + if got := buf.Bytes(); !bytes.Equal(got, want) { + t.Fatalf("MarshalInto() = %X, but wanted %X", got, want) + } + + *e = NameEntry{} + + if err := e.UnmarshalFrom(buf); err != nil { + t.Fatal("unexpected error:", err) + } + + if e.Filename != filename { + t.Errorf("UnmarhsalFrom(): Filename was %q, but expected %q", e.Filename, filename) + } + + if e.Longname != longname { + t.Errorf("UnmarhsalFrom(): Longname was %q, but expected %q", e.Longname, longname) + } + + if e.Attrs.Flags != AttrPermissions { + t.Errorf("UnmarshalBinary(): Attrs.Flag was %#x, but expected %#x", e.Attrs.Flags, AttrPermissions) + } + + if e.Attrs.Permissions != perms { + t.Errorf("UnmarshalBinary(): Attrs.Permissions was %#v, but expected %#v", e.Attrs.Permissions, perms) + } +} diff --git a/internal/encoding/ssh/filexfer/buffer.go b/internal/encoding/ssh/filexfer/buffer.go new file mode 100644 index 00000000..a6086036 --- /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 00000000..85ccce0e --- /dev/null +++ b/internal/encoding/ssh/filexfer/extended_packets.go @@ -0,0 +1,132 @@ +package filexfer + +import ( + "encoding" + "sync" +) + +// ExtendedData aliases the untyped interface composition of encoding.BinaryMarshaler and encoding.BinaryUnmarshaler. +type ExtendedData = interface { + encoding.BinaryMarshaler + encoding.BinaryUnmarshaler +} + +// ExtendedDataConstructor defines a function that returns a new(ArbitraryExtendedPacket). +type ExtendedDataConstructor func() ExtendedData + +var extendedPacketTypes = struct { + mu sync.RWMutex + constructors map[string]ExtendedDataConstructor +}{ + constructors: make(map[string]ExtendedDataConstructor), +} + +// RegisterExtendedPacketType defines a specific ExtendedDataConstructor for the given extension string. +func RegisterExtendedPacketType(extension string, constructor ExtendedDataConstructor) { + extendedPacketTypes.mu.Lock() + defer extendedPacketTypes.mu.Unlock() + + if _, exist := extendedPacketTypes.constructors[extension]; exist { + panic("encoding/ssh/filexfer: multiple registration of extended packet type " + extension) + } + + extendedPacketTypes.constructors[extension] = constructor +} + +func newExtendedPacket(extension string) ExtendedData { + extendedPacketTypes.mu.RLock() + defer extendedPacketTypes.mu.RUnlock() + + if f := extendedPacketTypes.constructors[extension]; f != nil { + return f() + } + + return new(Buffer) +} + +// ExtendedPacket defines the SSH_FXP_CLOSE packet. +type ExtendedPacket struct { + ExtendedRequest string + + Data ExtendedData +} + +// MarshalPacket returns p as a two-part binary encoding of p. +// +// The Data is marshaled into binary, and returned as the payload. +func (p *ExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.ExtendedRequest) // string(extended-request) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeExtended, reqid) + buf.AppendString(p.ExtendedRequest) + + if p.Data != nil { + payload, err = p.Data.MarshalBinary() + if err != nil { + return nil, nil, err + } + } + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +// +// If p.Data is nil, and the extension has been registered, a new type will be made from the registration. +// If the extension has not been registered, then a new Buffer will be allocated. +// Then the request-specific-data will be unmarshaled from the rest of the buffer. +func (p *ExtendedPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.ExtendedRequest, err = buf.ConsumeString(); err != nil { + return err + } + + if p.Data == nil { + p.Data = newExtendedPacket(p.ExtendedRequest) + } + + return p.Data.UnmarshalBinary(buf.Bytes()) +} + +// ExtendedReplyPacket defines the SSH_FXP_CLOSE packet. +type ExtendedReplyPacket struct { + Data ExtendedData +} + +// MarshalPacket returns p as a two-part binary encoding of p. +// +// The Data is marshaled into binary, and returned as the payload. +func (p *ExtendedReplyPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + buf = NewMarshalBuffer(0) + } + + buf.StartPacket(PacketTypeExtendedReply, reqid) + + if p.Data != nil { + payload, err = p.Data.MarshalBinary() + if err != nil { + return nil, nil, err + } + } + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +// +// If p.Data is nil, and there is request-specific-data, +// then the request-specific-data will be wrapped in a Buffer and assigned to p.Data. +func (p *ExtendedReplyPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Data == nil { + p.Data = new(Buffer) + } + + return p.Data.UnmarshalBinary(buf.Bytes()) +} diff --git a/internal/encoding/ssh/filexfer/extended_packets_test.go b/internal/encoding/ssh/filexfer/extended_packets_test.go new file mode 100644 index 00000000..668ef573 --- /dev/null +++ b/internal/encoding/ssh/filexfer/extended_packets_test.go @@ -0,0 +1,240 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +type testExtendedData struct { + value uint8 +} + +func (d *testExtendedData) MarshalBinary() ([]byte, error) { + buf := NewBuffer(make([]byte, 0, 4)) + + buf.AppendUint8(d.value ^ 0x2a) + + return buf.Bytes(), nil +} + +func (d *testExtendedData) UnmarshalBinary(data []byte) error { + buf := NewBuffer(data) + + v, err := buf.ConsumeUint8() + if err != nil { + return err + } + + d.value = v ^ 0x2a + + return nil +} + +var _ Packet = &ExtendedPacket{} + +func TestExtendedPacketNoData(t *testing.T) { + const ( + id = 42 + extendedRequest = "foo@example" + ) + + p := &ExtendedPacket{ + ExtendedRequest: extendedRequest, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 20, + 200, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ExtendedPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.ExtendedRequest != extendedRequest { + t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) + } +} + +func TestExtendedPacketTestData(t *testing.T) { + const ( + id = 42 + extendedRequest = "foo@example" + textValue = 13 + ) + + const value = 13 + + p := &ExtendedPacket{ + ExtendedRequest: extendedRequest, + Data: &testExtendedData{ + value: textValue, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 21, + 200, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 11, 'f', 'o', 'o', '@', 'e', 'x', 'a', 'm', 'p', 'l', 'e', + 0x27, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ExtendedPacket{ + Data: new(testExtendedData), + } + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.ExtendedRequest != extendedRequest { + t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) + } + + if data, ok := p.Data.(*testExtendedData); !ok { + t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data) + + } else if data.value != value { + t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", data.value, value) + } + + *p = ExtendedPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.ExtendedRequest != extendedRequest { + t.Errorf("UnmarshalPacketBody(): ExtendedRequest was %q, but expected %q", p.ExtendedRequest, extendedRequest) + } + + wantBuffer := []byte{0x27} + + if data, ok := p.Data.(*Buffer); !ok { + t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data) + + } else if !bytes.Equal(data.b, wantBuffer) { + t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", data.b, wantBuffer) + } +} + +var _ Packet = &ExtendedReplyPacket{} + +func TestExtendedReplyNoData(t *testing.T) { + const ( + id = 42 + ) + + p := &ExtendedReplyPacket{} + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 5, + 201, + 0x00, 0x00, 0x00, 42, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ExtendedReplyPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } +} + +func TestExtendedReplyPacketTestData(t *testing.T) { + const ( + id = 42 + textValue = 13 + ) + + const value = 13 + + p := &ExtendedReplyPacket{ + Data: &testExtendedData{ + value: textValue, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 6, + 201, + 0x00, 0x00, 0x00, 42, + 0x27, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ExtendedReplyPacket{ + Data: new(testExtendedData), + } + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if data, ok := p.Data.(*testExtendedData); !ok { + t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data) + + } else if data.value != value { + t.Errorf("UnmarshalPacketBody(): Data.value was %#x, but expected %#x", data.value, value) + } + + *p = ExtendedReplyPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + wantBuffer := []byte{0x27} + + if data, ok := p.Data.(*Buffer); !ok { + t.Errorf("UnmarshalPacketBody(): Data was type %T, but expected %T", p.Data, data) + + } else if !bytes.Equal(data.b, wantBuffer) { + t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", data.b, wantBuffer) + } +} diff --git a/internal/encoding/ssh/filexfer/extensions.go b/internal/encoding/ssh/filexfer/extensions.go new file mode 100644 index 00000000..23980b1e --- /dev/null +++ b/internal/encoding/ssh/filexfer/extensions.go @@ -0,0 +1,46 @@ +package filexfer + +// ExtensionPair defines the extension-pair type defined in draft-ietf-secsh-filexfer-13. +// This type is backwards-compatible with how draft-ietf-secsh-filexfer-02 defines extensions. +// +// Defined in: https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-4.2 +type ExtensionPair struct { + Name string + Data string +} + +// Len returns the number of bytes e would marshal into. +func (e *ExtensionPair) Len() int { + return 4 + len(e.Name) + 4 + len(e.Data) +} + +// MarshalInto marshals e onto the end of the given Buffer. +func (e *ExtensionPair) MarshalInto(buf *Buffer) { + buf.AppendString(e.Name) + buf.AppendString(e.Data) +} + +// MarshalBinary returns e as the binary encoding of e. +func (e *ExtensionPair) MarshalBinary() ([]byte, error) { + buf := NewBuffer(make([]byte, e.Len())) + e.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom unmarshals an ExtensionPair from the given Buffer into e. +func (e *ExtensionPair) UnmarshalFrom(buf *Buffer) (err error) { + if e.Name, err = buf.ConsumeString(); err != nil { + return err + } + + if e.Data, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the binary encoding of ExtensionPair into e. +func (e *ExtensionPair) UnmarshalBinary(data []byte) error { + return e.UnmarshalFrom(NewBuffer(data)) +} diff --git a/internal/encoding/ssh/filexfer/extensions_test.go b/internal/encoding/ssh/filexfer/extensions_test.go new file mode 100644 index 00000000..c145c038 --- /dev/null +++ b/internal/encoding/ssh/filexfer/extensions_test.go @@ -0,0 +1,48 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +func TestExtensionPair(t *testing.T) { + const ( + name = "foo" + data = "1" + ) + + pair := &ExtensionPair{ + Name: name, + Data: data, + } + + buf := new(Buffer) + + pair.MarshalInto(buf) + + want := []byte{ + 0x00, 0x00, 0x00, 3, + 'f', 'o', 'o', + 0x00, 0x00, 0x00, 1, + '1', + } + + if got := buf.Bytes(); !bytes.Equal(got, want) { + t.Errorf("ExtensionPair.MarshalInto() = %X, but wanted %X", got, want) + } + + *pair = ExtensionPair{} + + if err := pair.UnmarshalFrom(buf); err != nil { + t.Fatal("unexpected error:", err) + } + + if pair.Name != name { + t.Errorf("ExtensionPair.UnmarshalFrom(): Name was %q, but expected %q", pair.Name, name) + } + + if pair.Data != data { + t.Errorf("RawPacket.UnmarshalBinary(): Data was %q, but expected %q", pair.Data, data) + } + +} diff --git a/internal/encoding/ssh/filexfer/filexfer.go b/internal/encoding/ssh/filexfer/filexfer.go new file mode 100644 index 00000000..e8b2c341 --- /dev/null +++ b/internal/encoding/ssh/filexfer/filexfer.go @@ -0,0 +1,34 @@ +// Package filexfer implements the wire encoding for secsh-filexfer as described in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02 +package filexfer + +// Packet defines the behavior of an SFTP packet. +type Packet interface { + // MarshalPacket is the primary intended way to encode a packet. + // The request-id for the packet is set from reqid. + // + // An optional buffer may be given in b. + // If the buffer has a minimum capacity, it shall be truncated and used to marshal the header into. + // The minimum capacity for the packet must be a constant expression, and should be at least 9. + // + // It shall return the main body of the encoded packet in header, + // and may optionally return an additional payload to be written immediately after the header. + // + // It shall encode in the first 4-bytes of the header the proper length of the rest of the header+payload. + MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) + + // UnmarshalPacketBody decodes a packet body from the given Buffer. + // It is assumed that the common header values of the length, type and request-id have already been consumed. + UnmarshalPacketBody(buf *Buffer) error +} + +// ComposePacket converts returns from MarshalPacket into an equivalent call to MarshalBinary. +func ComposePacket(header, payload []byte, err error) ([]byte, error) { + return append(header, payload...), err +} + +// Default length values, +// Defined in draft-ietf-secsh-filexfer-02 section 3. +const ( + DefaultMaxPacketLength = 34000 + DefaultMaxDataLength = 32768 +) diff --git a/internal/encoding/ssh/filexfer/fx.go b/internal/encoding/ssh/filexfer/fx.go new file mode 100644 index 00000000..3eeac65b --- /dev/null +++ b/internal/encoding/ssh/filexfer/fx.go @@ -0,0 +1,137 @@ +package filexfer + +import ( + "fmt" +) + +// Status defines the SFTP error codes used in SSH_FXP_STATUS response packets. +type Status uint32 + +// Defines the various SSH_FX_* values. +const ( + // see draft-ietf-secsh-filexfer-02 + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-7 + StatusOK = Status(iota) + StatusEOF + StatusNoSuchFile + StatusPermissionDenied + StatusFailure + StatusBadMessage + StatusNoConnection + StatusConnectionLost + StatusOPUnsupported + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-03#section-7 + StatusV4InvalidHandle + StatusV4NoSuchPath + StatusV4FileAlreadyExists + StatusV4WriteProtect + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-04#section-7 + StatusV4NoMedia + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-05#section-7 + StatusV5NoSpaceOnFilesystem + StatusV5QuotaExceeded + StatusV5UnknownPrincipal + StatusV5LockConflict + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-06#section-8 + StatusV6DirNotEmpty + StatusV6NotADirectory + StatusV6InvalidFilename + StatusV6LinkLoop + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-07#section-8 + StatusV6CannotDelete + StatusV6InvalidParameter + StatusV6FileIsADirectory + StatusV6ByteRangeLockConflict + StatusV6ByteRangeLockRefused + StatusV6DeletePending + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-08#section-8.1 + StatusV6FileCorrupt + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-10#section-9.1 + StatusV6OwnerInvalid + StatusV6GroupInvalid + + // https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13#section-9.1 + StatusV6NoMatchingByteRangeLock +) + +func (f Status) Error() string { + return f.String() +} + +func (f Status) String() string { + switch f { + case StatusOK: + return "SSH_FX_OK" + case StatusEOF: + return "SSH_FX_EOF" + case StatusNoSuchFile: + return "SSH_FX_NO_SUCH_FILE" + case StatusPermissionDenied: + return "SSH_FX_PERMISSION_DENIED" + case StatusFailure: + return "SSH_FX_FAILURE" + case StatusBadMessage: + return "SSH_FX_BAD_MESSAGE" + case StatusNoConnection: + return "SSH_FX_NO_CONNECTION" + case StatusConnectionLost: + return "SSH_FX_CONNECTION_LOST" + case StatusOPUnsupported: + return "SSH_FX_OP_UNSUPPORTED" + case StatusV4InvalidHandle: + return "SSH_FX_INVALID_HANDLE" + case StatusV4NoSuchPath: + return "SSH_FX_NO_SUCH_PATH" + case StatusV4FileAlreadyExists: + return "SSH_FX_FILE_ALREADY_EXISTS" + case StatusV4WriteProtect: + return "SSH_FX_WRITE_PROTECT" + case StatusV4NoMedia: + return "SSH_FX_NO_MEDIA" + case StatusV5NoSpaceOnFilesystem: + return "SSH_FX_NO_SPACE_ON_FILESYSTEM" + case StatusV5QuotaExceeded: + return "SSH_FX_QUOTA_EXCEEDED" + case StatusV5UnknownPrincipal: + return "SSH_FX_UNKNOWN_PRINCIPAL" + case StatusV5LockConflict: + return "SSH_FX_LOCK_CONFLICT" + case StatusV6DirNotEmpty: + return "SSH_FX_DIR_NOT_EMPTY" + case StatusV6NotADirectory: + return "SSH_FX_NOT_A_DIRECTORY" + case StatusV6InvalidFilename: + return "SSH_FX_INVALID_FILENAME" + case StatusV6LinkLoop: + return "SSH_FX_LINK_LOOP" + case StatusV6CannotDelete: + return "SSH_FX_CANNOT_DELETE" + case StatusV6InvalidParameter: + return "SSH_FX_INVALID_PARAMETER" + case StatusV6FileIsADirectory: + return "SSH_FX_FILE_IS_A_DIRECTORY" + case StatusV6ByteRangeLockConflict: + return "SSH_FX_BYTE_RANGE_LOCK_CONFLICT" + case StatusV6ByteRangeLockRefused: + return "SSH_FX_BYTE_RANGE_LOCK_REFUSED" + case StatusV6DeletePending: + return "SSH_FX_DELETE_PENDING" + case StatusV6FileCorrupt: + return "SSH_FX_FILE_CORRUPT" + case StatusV6OwnerInvalid: + return "SSH_FX_OWNER_INVALID" + case StatusV6GroupInvalid: + return "SSH_FX_GROUP_INVALID" + case StatusV6NoMatchingByteRangeLock: + return "SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK" + default: + return fmt.Sprintf("SSH_FX_UNKNOWN(%d)", f) + } +} diff --git a/internal/encoding/ssh/filexfer/fx_test.go b/internal/encoding/ssh/filexfer/fx_test.go new file mode 100644 index 00000000..4d321d97 --- /dev/null +++ b/internal/encoding/ssh/filexfer/fx_test.go @@ -0,0 +1,84 @@ +package filexfer + +import ( + "bufio" + "regexp" + "strconv" + "strings" + "testing" +) + +// This string data is copied verbatim from https://tools.ietf.org/html/draft-ietf-secsh-filexfer-13 +var fxStandardsText = ` +SSH_FX_OK 0 +SSH_FX_EOF 1 +SSH_FX_NO_SUCH_FILE 2 +SSH_FX_PERMISSION_DENIED 3 +SSH_FX_FAILURE 4 +SSH_FX_BAD_MESSAGE 5 +SSH_FX_NO_CONNECTION 6 +SSH_FX_CONNECTION_LOST 7 +SSH_FX_OP_UNSUPPORTED 8 +SSH_FX_INVALID_HANDLE 9 +SSH_FX_NO_SUCH_PATH 10 +SSH_FX_FILE_ALREADY_EXISTS 11 +SSH_FX_WRITE_PROTECT 12 +SSH_FX_NO_MEDIA 13 +SSH_FX_NO_SPACE_ON_FILESYSTEM 14 +SSH_FX_QUOTA_EXCEEDED 15 +SSH_FX_UNKNOWN_PRINCIPAL 16 +SSH_FX_LOCK_CONFLICT 17 +SSH_FX_DIR_NOT_EMPTY 18 +SSH_FX_NOT_A_DIRECTORY 19 +SSH_FX_INVALID_FILENAME 20 +SSH_FX_LINK_LOOP 21 +SSH_FX_CANNOT_DELETE 22 +SSH_FX_INVALID_PARAMETER 23 +SSH_FX_FILE_IS_A_DIRECTORY 24 +SSH_FX_BYTE_RANGE_LOCK_CONFLICT 25 +SSH_FX_BYTE_RANGE_LOCK_REFUSED 26 +SSH_FX_DELETE_PENDING 27 +SSH_FX_FILE_CORRUPT 28 +SSH_FX_OWNER_INVALID 29 +SSH_FX_GROUP_INVALID 30 +SSH_FX_NO_MATCHING_BYTE_RANGE_LOCK 31 +` + +func TestFxNames(t *testing.T) { + whitespace := regexp.MustCompile(`[[:space:]]+`) + + scan := bufio.NewScanner(strings.NewReader(fxStandardsText)) + + for scan.Scan() { + line := scan.Text() + if i := strings.Index(line, "//"); i >= 0 { + line = line[:i] + } + + line = strings.TrimSpace(line) + if line == "" { + continue + } + + fields := whitespace.Split(line, 2) + if len(fields) < 2 { + t.Fatalf("unexpected standards text line: %q", line) + } + + name, value := fields[0], fields[1] + n, err := strconv.Atoi(value) + if err != nil { + t.Fatal("unexpected error:", err) + } + + fx := Status(n) + + if got := fx.String(); got != name { + t.Errorf("fx name mismatch for %d: got %q, but want %q", n, got, name) + } + } + + if err := scan.Err(); err != nil { + t.Fatal("unexpected error:", err) + } +} diff --git a/internal/encoding/ssh/filexfer/fxp.go b/internal/encoding/ssh/filexfer/fxp.go new file mode 100644 index 00000000..15caf6d2 --- /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 00000000..14e8ff73 --- /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 00000000..33670dc5 --- /dev/null +++ b/internal/encoding/ssh/filexfer/handle_packets.go @@ -0,0 +1,204 @@ +package filexfer + +// ClosePacket defines the SSH_FXP_CLOSE packet. +type ClosePacket struct { + Handle string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *ClosePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Handle) // string(handle) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeClose, reqid) + buf.AppendString(p.Handle) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *ClosePacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// ReadPacket defines the SSH_FXP_READ packet. +type ReadPacket struct { + Handle string + Offset uint64 + Len uint32 +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *ReadPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + // string(handle) + uint64(offset) + uint32(len) + size := 4 + len(p.Handle) + 8 + 4 + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeRead, reqid) + buf.AppendString(p.Handle) + buf.AppendUint64(p.Offset) + buf.AppendUint32(p.Len) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *ReadPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + if p.Offset, err = buf.ConsumeUint64(); err != nil { + return err + } + + if p.Len, err = buf.ConsumeUint32(); err != nil { + return err + } + + return nil +} + +// WritePacket defines the SSH_FXP_WRITE packet. +type WritePacket struct { + Handle string + Offset uint64 + Data []byte +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *WritePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + // string(handle) + uint64(offset) + uint32(len(data)); data content in payload + size := 4 + len(p.Handle) + 8 + 4 + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeWrite, reqid) + buf.AppendString(p.Handle) + buf.AppendUint64(p.Offset) + buf.AppendUint32(uint32(len(p.Data))) + + return buf.Packet(p.Data) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *WritePacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + if p.Offset, err = buf.ConsumeUint64(); err != nil { + return err + } + + if p.Data, err = buf.ConsumeByteSlice(); err != nil { + return err + } + + return nil +} + +// FStatPacket defines the SSH_FXP_FSTAT packet. +type FStatPacket struct { + Handle string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *FStatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Handle) // string(handle) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeFStat, reqid) + buf.AppendString(p.Handle) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *FStatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// FSetstatPacket defines the SSH_FXP_FSETSTAT packet. +type FSetstatPacket struct { + Handle string + Attrs Attributes +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *FSetstatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Handle) + p.Attrs.Len() // string(handle) + ATTRS(attrs) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeFSetstat, reqid) + buf.AppendString(p.Handle) + + p.Attrs.MarshalInto(buf) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *FSetstatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + return p.Attrs.UnmarshalFrom(buf) +} + +// ReadDirPacket defines the SSH_FXP_READDIR packet. +type ReadDirPacket struct { + Handle string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *ReadDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Handle) // string(handle) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeReadDir, reqid) + buf.AppendString(p.Handle) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *ReadDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Handle, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} diff --git a/internal/encoding/ssh/filexfer/handle_packets_test.go b/internal/encoding/ssh/filexfer/handle_packets_test.go new file mode 100644 index 00000000..10fdc53a --- /dev/null +++ b/internal/encoding/ssh/filexfer/handle_packets_test.go @@ -0,0 +1,282 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +var _ Packet = &ClosePacket{} + +func TestClosePacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + ) + + p := &ClosePacket{ + Handle: "somehandle", + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 19, + 4, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ClosePacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } +} + +var _ Packet = &ReadPacket{} + +func TestReadPacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + offset = 0x123456789ABCDEF0 + length = 0xFEDCBA98 + ) + + p := &ReadPacket{ + Handle: "somehandle", + Offset: offset, + Len: length, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 31, + 5, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, + 0xFE, 0xDC, 0xBA, 0x98, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ReadPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } + + if p.Offset != offset { + t.Errorf("UnmarshalPacketBody(): Offset was %x, but expected %x", p.Offset, offset) + } + + if p.Len != length { + t.Errorf("UnmarshalPacketBody(): Len was %x, but expected %x", p.Len, length) + } +} + +var _ Packet = &WritePacket{} + +func TestWritePacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + offset = 0x123456789ABCDEF0 + ) + + var payload = []byte(`foobar`) + + p := &WritePacket{ + Handle: "somehandle", + Offset: offset, + Data: payload, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 37, + 6, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + 0x12, 0x34, 0x56, 0x78, 0x9A, 0xBC, 0xDE, 0xF0, + 0x00, 0x00, 0x00, 0x06, 'f', 'o', 'o', 'b', 'a', 'r', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = WritePacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } + + if p.Offset != offset { + t.Errorf("UnmarshalPacketBody(): Offset was %x, but expected %x", p.Offset, offset) + } + + if !bytes.Equal(p.Data, payload) { + t.Errorf("UnmarshalPacketBody(): Data was %X, but expected %X", p.Data, payload) + } +} + +var _ Packet = &FStatPacket{} + +func TestFStatPacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + ) + + p := &FStatPacket{ + Handle: "somehandle", + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 19, + 8, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = FStatPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } +} + +var _ Packet = &FSetstatPacket{} + +func TestFSetstatPacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + perms = 0x87654321 + ) + + p := &FSetstatPacket{ + Handle: "somehandle", + Attrs: Attributes{ + Flags: AttrPermissions, + Permissions: perms, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 27, + 10, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = FSetstatPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } +} + +var _ Packet = &ReadDirPacket{} + +func TestReadDirPacket(t *testing.T) { + const ( + id = 42 + handle = "somehandle" + ) + + p := &ReadDirPacket{ + Handle: "somehandle", + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 19, + 12, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 10, 's', 'o', 'm', 'e', 'h', 'a', 'n', 'd', 'l', 'e', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ReadDirPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Handle != handle { + t.Errorf("UnmarshalPacketBody(): Handle was %q, but expected %q", p.Handle, handle) + } +} diff --git a/internal/encoding/ssh/filexfer/init_packets.go b/internal/encoding/ssh/filexfer/init_packets.go new file mode 100644 index 00000000..b3e99dcb --- /dev/null +++ b/internal/encoding/ssh/filexfer/init_packets.go @@ -0,0 +1,99 @@ +package filexfer + +// InitPacket defines the SSH_FXP_INIT packet. +type InitPacket struct { + Version uint32 + Extensions []*ExtensionPair +} + +// MarshalBinary returns p as the binary encoding of p. +func (p *InitPacket) MarshalBinary() ([]byte, error) { + size := 1 + 4 // byte(type) + uint32(version) + + for _, ext := range p.Extensions { + size += ext.Len() + } + + b := NewBuffer(make([]byte, 4, 4+size)) + b.AppendUint8(uint8(PacketTypeInit)) + b.AppendUint32(p.Version) + + for _, ext := range p.Extensions { + ext.MarshalInto(b) + } + + b.PutLength(size) + + return b.Bytes(), nil +} + +// UnmarshalBinary unmarshals a full raw packet out of the given data. +// It is assumed that the uint32(length) has already been consumed to receive the data. +// It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into. +func (p *InitPacket) UnmarshalBinary(data []byte) (err error) { + buf := NewBuffer(data) + + if p.Version, err = buf.ConsumeUint32(); err != nil { + return err + } + + for buf.Len() > 0 { + var ext ExtensionPair + if err := ext.UnmarshalFrom(buf); err != nil { + return err + } + + p.Extensions = append(p.Extensions, &ext) + } + + return nil +} + +// VersionPacket defines the SSH_FXP_VERSION packet. +type VersionPacket struct { + Version uint32 + Extensions []*ExtensionPair +} + +// MarshalBinary returns p as the binary encoding of p. +func (p *VersionPacket) MarshalBinary() ([]byte, error) { + size := 1 + 4 // byte(type) + uint32(version) + + for _, ext := range p.Extensions { + size += ext.Len() + } + + b := NewBuffer(make([]byte, 4, 4+size)) + b.AppendUint8(uint8(PacketTypeInit)) + b.AppendUint32(p.Version) + + for _, ext := range p.Extensions { + ext.MarshalInto(b) + } + + b.PutLength(size) + + return b.Bytes(), nil +} + +// UnmarshalBinary unmarshals a full raw packet out of the given data. +// It is assumed that the uint32(length) has already been consumed to receive the data. +// It is also assumed that the uint8(type) has already been consumed to which packet to unmarshal into. +func (p *VersionPacket) UnmarshalBinary(data []byte) (err error) { + buf := NewBuffer(data) + + if p.Version, err = buf.ConsumeUint32(); err != nil { + return err + } + + for buf.Len() > 0 { + var ext ExtensionPair + if err := ext.UnmarshalFrom(buf); err != nil { + return err + } + + p.Extensions = append(p.Extensions, &ext) + } + + return nil +} diff --git a/internal/encoding/ssh/filexfer/init_packets_test.go b/internal/encoding/ssh/filexfer/init_packets_test.go new file mode 100644 index 00000000..0cab7528 --- /dev/null +++ b/internal/encoding/ssh/filexfer/init_packets_test.go @@ -0,0 +1,114 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +func TestInitPacket(t *testing.T) { + var version uint8 = 3 + + p := &InitPacket{ + Version: uint32(version), + Extensions: []*ExtensionPair{ + { + Name: "foo", + Data: "1", + }, + }, + } + + data, err := p.MarshalBinary() + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 17, + 1, + 0x00, 0x00, 0x00, version, + 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', + 0x00, 0x00, 0x00, 1, '1', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = InitPacket{} + + // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed. + if err := p.UnmarshalBinary(data[5:]); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Version != uint32(version) { + t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version) + } + + if len(p.Extensions) != 1 { + t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1) + } + + if got, want := p.Extensions[0].Name, "foo"; got != want { + t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want) + } + + if got, want := p.Extensions[0].Data, "1"; got != want { + t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want) + } +} + +func TestVersionPacket(t *testing.T) { + var version uint8 = 3 + + p := &VersionPacket{ + Version: uint32(version), + Extensions: []*ExtensionPair{ + { + Name: "foo", + Data: "1", + }, + }, + } + + data, err := p.MarshalBinary() + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 17, + 1, + 0x00, 0x00, 0x00, version, + 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', + 0x00, 0x00, 0x00, 1, '1', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = VersionPacket{} + + // UnmarshalBinary assumes the uint32(length) + uint8(type) have already been consumed. + if err := p.UnmarshalBinary(data[5:]); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Version != uint32(version) { + t.Errorf("UnmarshalBinary(): Version was %d, but expected %d", p.Version, version) + } + + if len(p.Extensions) != 1 { + t.Fatalf("UnmarshalBinary(): len(p.Extensions) was %d, but expected %d", len(p.Extensions), 1) + } + + if got, want := p.Extensions[0].Name, "foo"; got != want { + t.Errorf("UnmarshalBinary(): p.Extensions[0].Name was %q, but expected %q", got, want) + } + + if got, want := p.Extensions[0].Data, "1"; got != want { + t.Errorf("UnmarshalBinary(): p.Extensions[0].Data was %q, but expected %q", got, want) + } +} diff --git a/internal/encoding/ssh/filexfer/open_packets.go b/internal/encoding/ssh/filexfer/open_packets.go new file mode 100644 index 00000000..b0e25c2c --- /dev/null +++ b/internal/encoding/ssh/filexfer/open_packets.go @@ -0,0 +1,79 @@ +package filexfer + +// SSH_FXF_* flags. +const ( + FlagRead = 1 << iota // SSH_FXF_READ + FlagWrite // SSH_FXF_WRITE + FlagAppend // SSH_FXF_APPEND + FlagCreate // SSH_FXF_CREAT + FlagTruncate // SSH_FXF_TRUNC + FlagExclusive // SSH_FXF_EXCL +) + +// OpenPacket defines the SSH_FXP_OPEN packet. +type OpenPacket struct { + Filename string + PFlags uint32 + Attrs Attributes +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *OpenPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + // string(filename) + uint32(pflags) + ATTRS(attrs) + size := 4 + len(p.Filename) + 4 + p.Attrs.Len() + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeOpen, reqid) + buf.AppendString(p.Filename) + buf.AppendUint32(p.PFlags) + + p.Attrs.MarshalInto(buf) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *OpenPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Filename, err = buf.ConsumeString(); err != nil { + return err + } + + if p.PFlags, err = buf.ConsumeUint32(); err != nil { + return err + } + + return p.Attrs.UnmarshalFrom(buf) +} + +// OpenDirPacket defines the SSH_FXP_OPENDIR packet. +type OpenDirPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *OpenDirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeOpenDir, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *OpenDirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} diff --git a/internal/encoding/ssh/filexfer/open_packets_test.go b/internal/encoding/ssh/filexfer/open_packets_test.go new file mode 100644 index 00000000..8637cd0c --- /dev/null +++ b/internal/encoding/ssh/filexfer/open_packets_test.go @@ -0,0 +1,107 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +var _ Packet = &OpenPacket{} + +func TestOpenPacket(t *testing.T) { + const ( + id = 42 + filename = "/foo" + perms = 0x87654321 + ) + + p := &OpenPacket{ + Filename: "/foo", + PFlags: FlagRead, + Attrs: Attributes{ + Flags: AttrPermissions, + Permissions: perms, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 25, + 3, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + 0x00, 0x00, 0x00, 1, + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = OpenPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Filename != filename { + t.Errorf("UnmarshalPacketBody(): Filename was %q, but expected %q", p.Filename, filename) + } + + if p.PFlags != FlagRead { + t.Errorf("UnmarshalPacketBody(): PFlags was %#x, but expected %#x", p.PFlags, FlagRead) + } + + if p.Attrs.Flags != AttrPermissions { + t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions) + } + + if p.Attrs.Permissions != perms { + t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms) + } +} + +var _ Packet = &OpenDirPacket{} + +func TestOpenDirPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &OpenDirPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 11, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = OpenDirPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} diff --git a/internal/encoding/ssh/filexfer/openssh/hardlink.go b/internal/encoding/ssh/filexfer/openssh/hardlink.go new file mode 100644 index 00000000..7f7d5e07 --- /dev/null +++ b/internal/encoding/ssh/filexfer/openssh/hardlink.go @@ -0,0 +1,74 @@ +package openssh + +import ( + sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" +) + +const extensionHardlink = "hardlink@openssh.com" + +// RegisterExtensionHardlink registers the "hardlink@openssh.com" extended packet with the encoding/ssh/filexfer package. +func RegisterExtensionHardlink() { + sshfx.RegisterExtendedPacketType(extensionHardlink, func() sshfx.ExtendedData { + return new(HardlinkExtendedPacket) + }) +} + +// ExtensionHardlink returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. +func ExtensionHardlink() *sshfx.ExtensionPair { + return &sshfx.ExtensionPair{ + Name: extensionHardlink, + Data: "1", + } +} + +// HardlinkExtendedPacket defines the hardlink@openssh.com extend packet. +type HardlinkExtendedPacket struct { + OldPath string + NewPath string +} + +// MarshalPacket returns ep as a two-part binary encoding of the full extended packet. +func (ep *HardlinkExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + p := &sshfx.ExtendedPacket{ + ExtendedRequest: extensionHardlink, + + Data: ep, + } + return p.MarshalPacket(reqid, b) +} + +// MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. +func (ep *HardlinkExtendedPacket) MarshalInto(buf *sshfx.Buffer) { + buf.AppendString(ep.OldPath) + buf.AppendString(ep.NewPath) +} + +// MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. +// +// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. +func (ep *HardlinkExtendedPacket) MarshalBinary() ([]byte, error) { + // string(oldpath) + string(newpath) + size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath) + + buf := sshfx.NewBuffer(make([]byte, size)) + ep.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf. +func (ep *HardlinkExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { + if ep.OldPath, err = buf.ConsumeString(); err != nil { + return err + } + + if ep.NewPath, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep. +func (ep *HardlinkExtendedPacket) UnmarshalBinary(data []byte) (err error) { + return ep.UnmarshalFrom(sshfx.NewBuffer(data)) +} diff --git a/internal/encoding/ssh/filexfer/openssh/openssh.go b/internal/encoding/ssh/filexfer/openssh/openssh.go new file mode 100644 index 00000000..f93ff177 --- /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 00000000..d19a5801 --- /dev/null +++ b/internal/encoding/ssh/filexfer/openssh/posix-rename.go @@ -0,0 +1,74 @@ +package openssh + +import ( + sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" +) + +const extensionPosixRename = "posix-rename@openssh.com" + +// RegisterExtensionPosixRename registers the "posix-rename@openssh.com" extended packet with the encoding/ssh/filexfer package. +func RegisterExtensionPosixRename() { + sshfx.RegisterExtendedPacketType(extensionPosixRename, func() sshfx.ExtendedData { + return new(PosixRenameExtendedPacket) + }) +} + +// ExtensionPosixRename returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. +func ExtensionPosixRename() *sshfx.ExtensionPair { + return &sshfx.ExtensionPair{ + Name: extensionPosixRename, + Data: "1", + } +} + +// PosixRenameExtendedPacket defines the posix-rename@openssh.com extend packet. +type PosixRenameExtendedPacket struct { + OldPath string + NewPath string +} + +// MarshalPacket returns ep as a two-part binary encoding of the full extended packet. +func (ep *PosixRenameExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + p := &sshfx.ExtendedPacket{ + ExtendedRequest: extensionPosixRename, + + Data: ep, + } + return p.MarshalPacket(reqid, b) +} + +// MarshalInto encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. +func (ep *PosixRenameExtendedPacket) MarshalInto(buf *sshfx.Buffer) { + buf.AppendString(ep.OldPath) + buf.AppendString(ep.NewPath) +} + +// MarshalBinary encodes ep into the binary encoding of the hardlink@openssh.com extended packet-specific data. +// +// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. +func (ep *PosixRenameExtendedPacket) MarshalBinary() ([]byte, error) { + // string(oldpath) + string(newpath) + size := 4 + len(ep.OldPath) + 4 + len(ep.NewPath) + + buf := sshfx.NewBuffer(make([]byte, size)) + ep.MarshalInto(buf) + return buf.Bytes(), nil +} + +// UnmarshalFrom decodes the hardlink@openssh.com extended packet-specific data from buf. +func (ep *PosixRenameExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { + if ep.OldPath, err = buf.ConsumeString(); err != nil { + return err + } + + if ep.NewPath, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the hardlink@openssh.com extended packet-specific data into ep. +func (ep *PosixRenameExtendedPacket) UnmarshalBinary(data []byte) (err error) { + return ep.UnmarshalFrom(sshfx.NewBuffer(data)) +} diff --git a/internal/encoding/ssh/filexfer/openssh/statvfs.go b/internal/encoding/ssh/filexfer/openssh/statvfs.go new file mode 100644 index 00000000..a58fc6a7 --- /dev/null +++ b/internal/encoding/ssh/filexfer/openssh/statvfs.go @@ -0,0 +1,233 @@ +package openssh + +import ( + sshfx "github.com/pkg/sftp/internal/encoding/ssh/filexfer" +) + +const extensionStatVFS = "statvfs@openssh.com" + +// RegisterExtensionStatVFS registers the "statvfs@openssh.com" extended packet with the encoding/ssh/filexfer package. +func RegisterExtensionStatVFS() { + sshfx.RegisterExtendedPacketType(extensionStatVFS, func() sshfx.ExtendedData { + return new(StatVFSExtendedPacket) + }) +} + +// ExtensionStatVFS returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. +func ExtensionStatVFS() *sshfx.ExtensionPair { + return &sshfx.ExtensionPair{ + Name: extensionStatVFS, + Data: "2", + } +} + +// StatVFSExtendedPacket defines the statvfs@openssh.com extend packet. +type StatVFSExtendedPacket struct { + Path string +} + +// MarshalPacket returns ep as a two-part binary encoding of the full extended packet. +func (ep *StatVFSExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + p := &sshfx.ExtendedPacket{ + ExtendedRequest: extensionStatVFS, + + Data: ep, + } + return p.MarshalPacket(reqid, b) +} + +// MarshalInto encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data. +func (ep *StatVFSExtendedPacket) MarshalInto(buf *sshfx.Buffer) { + buf.AppendString(ep.Path) +} + +// MarshalBinary encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data. +// +// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. +func (ep *StatVFSExtendedPacket) MarshalBinary() ([]byte, error) { + size := 4 + len(ep.Path) // string(path) + + buf := sshfx.NewBuffer(make([]byte, size)) + + ep.MarshalInto(buf) + + return buf.Bytes(), nil +} + +// UnmarshalFrom decodes the statvfs@openssh.com extended packet-specific data into ep. +func (ep *StatVFSExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { + if ep.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the statvfs@openssh.com extended packet-specific data into ep. +func (ep *StatVFSExtendedPacket) UnmarshalBinary(data []byte) (err error) { + return ep.UnmarshalFrom(sshfx.NewBuffer(data)) +} + +const extensionFStatVFS = "fstatvfs@openssh.com" + +// RegisterExtensionFStatVFS registers the "fstatvfs@openssh.com" extended packet with the encoding/ssh/filexfer package. +func RegisterExtensionFStatVFS() { + sshfx.RegisterExtendedPacketType(extensionFStatVFS, func() sshfx.ExtendedData { + return new(FStatVFSExtendedPacket) + }) +} + +// ExtensionFStatVFS returns an ExtensionPair suitable to append into an sshfx.InitPacket or sshfx.VersionPacket. +func ExtensionFStatVFS() *sshfx.ExtensionPair { + return &sshfx.ExtensionPair{ + Name: extensionFStatVFS, + Data: "2", + } +} + +// FStatVFSExtendedPacket defines the fstatvfs@openssh.com extend packet. +type FStatVFSExtendedPacket struct { + Path string +} + +// MarshalPacket returns ep as a two-part binary encoding of the full extended packet. +func (ep *FStatVFSExtendedPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + p := &sshfx.ExtendedPacket{ + ExtendedRequest: extensionFStatVFS, + + Data: ep, + } + return p.MarshalPacket(reqid, b) +} + +// MarshalInto encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data. +func (ep *FStatVFSExtendedPacket) MarshalInto(buf *sshfx.Buffer) { + buf.AppendString(ep.Path) +} + +// MarshalBinary encodes ep into the binary encoding of the statvfs@openssh.com extended packet-specific data. +// +// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended packet. +func (ep *FStatVFSExtendedPacket) MarshalBinary() ([]byte, error) { + size := 4 + len(ep.Path) // string(path) + + buf := sshfx.NewBuffer(make([]byte, size)) + + ep.MarshalInto(buf) + + return buf.Bytes(), nil +} + +// UnmarshalFrom decodes the statvfs@openssh.com extended packet-specific data into ep. +func (ep *FStatVFSExtendedPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { + if ep.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the statvfs@openssh.com extended packet-specific data into ep. +func (ep *FStatVFSExtendedPacket) UnmarshalBinary(data []byte) (err error) { + return ep.UnmarshalFrom(sshfx.NewBuffer(data)) +} + +// The values for the MountFlags field. +// https://github.com/openssh/openssh-portable/blob/master/PROTOCOL +const ( + MountFlagsReadOnly = 0x1 // SSH_FXE_STATVFS_ST_RDONLY + MountFlagsNoSUID = 0x2 // SSH_FXE_STATVFS_ST_NOSUID +) + +// StatVFSExtendedReplyPacket defines the extended reply packet for statvfs@openssh.com and fstatvfs@openssh.com requests. +type StatVFSExtendedReplyPacket struct { + BlockSize uint64 /* f_bsize: file system block size */ + FragmentSize uint64 /* f_frsize: fundamental fs block size / fagment size */ + Blocks uint64 /* f_blocks: number of blocks (unit f_frsize) */ + BlocksFree uint64 /* f_bfree: free blocks in filesystem */ + BlocksAvail uint64 /* f_bavail: free blocks for non-root */ + Files uint64 /* f_files: total file inodes */ + FilesFree uint64 /* f_ffree: free file inodes */ + FilesAvail uint64 /* f_favail: free file inodes for to non-root */ + FilesystemID uint64 /* f_fsid: file system id */ + MountFlags uint64 /* f_flag: bit mask of mount flag values */ + MaxNameLength uint64 /* f_namemax: maximum filename length */ +} + +// MarshalPacket returns ep as a two-part binary encoding of the full extended reply packet. +func (ep *StatVFSExtendedReplyPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + p := &sshfx.ExtendedReplyPacket{ + Data: ep, + } + return p.MarshalPacket(reqid, b) +} + +// MarshalInto encodes ep into the binary encoding of the (f)statvfs@openssh.com extended reply packet-specific data. +func (ep *StatVFSExtendedReplyPacket) MarshalInto(buf *sshfx.Buffer) { + buf.AppendUint64(ep.BlockSize) + buf.AppendUint64(ep.FragmentSize) + buf.AppendUint64(ep.Blocks) + buf.AppendUint64(ep.BlocksFree) + buf.AppendUint64(ep.BlocksAvail) + buf.AppendUint64(ep.Files) + buf.AppendUint64(ep.FilesFree) + buf.AppendUint64(ep.FilesAvail) + buf.AppendUint64(ep.FilesystemID) + buf.AppendUint64(ep.MountFlags) + buf.AppendUint64(ep.MaxNameLength) +} + +// MarshalBinary encodes ep into the binary encoding of the (f)statvfs@openssh.com extended reply packet-specific data. +// +// NOTE: This _only_ encodes the packet-specific data, it does not encode the full extended reply packet. +func (ep *StatVFSExtendedReplyPacket) MarshalBinary() ([]byte, error) { + size := 11 * 8 // 11 × uint64(various) + + b := sshfx.NewBuffer(make([]byte, size)) + ep.MarshalInto(b) + return b.Bytes(), nil +} + +// UnmarshalFrom decodes the fstatvfs@openssh.com extended reply packet-specific data into ep. +func (ep *StatVFSExtendedReplyPacket) UnmarshalFrom(buf *sshfx.Buffer) (err error) { + if ep.BlockSize, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.FragmentSize, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.Blocks, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.BlocksFree, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.BlocksAvail, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.Files, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.FilesFree, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.FilesAvail, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.FilesystemID, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.MountFlags, err = buf.ConsumeUint64(); err != nil { + return err + } + if ep.MaxNameLength, err = buf.ConsumeUint64(); err != nil { + return err + } + + return nil +} + +// UnmarshalBinary decodes the fstatvfs@openssh.com extended reply packet-specific data into ep. +func (ep *StatVFSExtendedReplyPacket) UnmarshalBinary(data []byte) (err error) { + return ep.UnmarshalFrom(sshfx.NewBuffer(data)) +} diff --git a/internal/encoding/ssh/filexfer/packets.go b/internal/encoding/ssh/filexfer/packets.go new file mode 100644 index 00000000..d597f494 --- /dev/null +++ b/internal/encoding/ssh/filexfer/packets.go @@ -0,0 +1,313 @@ +package filexfer + +import ( + "errors" + "fmt" + "io" +) + +// smallBufferSize is an initial allocation minimal capacity. +const smallBufferSize = 64 + +func newPacketFromType(typ PacketType) (Packet, error) { + switch typ { + case PacketTypeOpen: + return new(OpenPacket), nil + case PacketTypeClose: + return new(ClosePacket), nil + case PacketTypeRead: + return new(ReadPacket), nil + case PacketTypeWrite: + return new(WritePacket), nil + case PacketTypeLStat: + return new(LStatPacket), nil + case PacketTypeFStat: + return new(FStatPacket), nil + case PacketTypeSetstat: + return new(SetstatPacket), nil + case PacketTypeFSetstat: + return new(FSetstatPacket), nil + case PacketTypeOpenDir: + return new(OpenDirPacket), nil + case PacketTypeReadDir: + return new(ReadDirPacket), nil + case PacketTypeRemove: + return new(RemovePacket), nil + case PacketTypeMkdir: + return new(MkdirPacket), nil + case PacketTypeRmdir: + return new(RmdirPacket), nil + case PacketTypeRealPath: + return new(RealPathPacket), nil + case PacketTypeStat: + return new(StatPacket), nil + case PacketTypeRename: + return new(RenamePacket), nil + case PacketTypeReadLink: + return new(ReadLinkPacket), nil + case PacketTypeSymlink: + return new(SymlinkPacket), nil + case PacketTypeExtended: + return new(ExtendedPacket), nil + default: + return nil, fmt.Errorf("unexpected request packet type: %v", typ) + } +} + +// RawPacket implements the general packet format from draft-ietf-secsh-filexfer-02 +// +// RawPacket is intended for use in clients receiving responses, +// where a response will be expected to be of a limited number of types, +// and unmarshaling unknown/unexpected response packets is unnecessary. +// +// For servers expecting to receive arbitrary request packet types, +// use RequestPacket. +// +// Defined in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3 +type RawPacket struct { + Type PacketType + RequestID uint32 + + Data Buffer +} + +// Reset clears the pointers and reference-semantic variables of RawPacket, +// releasing underlying resources, and making them and the RawPacket suitable to be reused, +// so long as no other references have been kept. +func (p *RawPacket) Reset() { + p.Data = Buffer{} +} + +// MarshalPacket returns p as a two-part binary encoding of p. +// +// The internal p.RequestID is overridden by the reqid argument. +func (p *RawPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + buf = NewMarshalBuffer(0) + } + + buf.StartPacket(p.Type, reqid) + + return buf.Packet(p.Data.Bytes()) +} + +// MarshalBinary returns p as the binary encoding of p. +// +// This is a convenience implementation primarily intended for tests, +// because it is inefficient with allocations. +func (p *RawPacket) MarshalBinary() ([]byte, error) { + return ComposePacket(p.MarshalPacket(p.RequestID, nil)) +} + +// UnmarshalFrom decodes a RawPacket from the given Buffer into p. +// +// The Data field will alias the passed in Buffer, +// so the buffer passed in should not be reused before RawPacket.Reset(). +func (p *RawPacket) UnmarshalFrom(buf *Buffer) error { + typ, err := buf.ConsumeUint8() + if err != nil { + return err + } + + p.Type = PacketType(typ) + + if p.RequestID, err = buf.ConsumeUint32(); err != nil { + return err + } + + p.Data = *buf + return nil +} + +// UnmarshalBinary decodes a full raw packet out of the given data. +// It is assumed that the uint32(length) has already been consumed to receive the data. +// +// This is a convenience implementation primarily intended for tests, +// because this must clone the given data byte slice, +// as Data is not allowed to alias any part of the data byte slice. +func (p *RawPacket) UnmarshalBinary(data []byte) error { + clone := make([]byte, len(data)) + n := copy(clone, data) + return p.UnmarshalFrom(NewBuffer(clone[:n])) +} + +// readPacket reads a uint32 length-prefixed binary data packet from r. +// using the given byte slice as a backing array. +// +// If the packet length read from r is bigger than maxPacketLength, +// or greater than math.MaxInt32 on a 32-bit implementation, +// then a `ErrLongPacket` error will be returned. +// +// If the given byte slice is insufficient to hold the packet, +// then it will be extended to fill the packet size. +func readPacket(r io.Reader, b []byte, maxPacketLength uint32) ([]byte, error) { + if cap(b) < 4 { + // We will need allocate our own buffer just for reading the packet length. + + // However, we don’t really want to allocate an extremely narrow buffer (4-bytes), + // and cause unnecessary allocation churn from both length reads and small packet reads, + // so we use smallBufferSize from the bytes package as a reasonable guess. + + // But if callers really do want to force narrow throw-away allocation of every packet body, + // they can do so with a buffer of capacity 4. + b = make([]byte, smallBufferSize) + } + + if _, err := io.ReadFull(r, b[:4]); err != nil { + return nil, err + } + + length := unmarshalUint32(b) + if int(length) < 5 { + // Must have at least uint8(type) and uint32(request-id) + + if int(length) < 0 { + // Only possible when strconv.IntSize == 32, + // the packet length is longer than math.MaxInt32, + // and thus longer than any possible slice. + return nil, ErrLongPacket + } + + return nil, ErrShortPacket + } + if length > maxPacketLength { + return nil, ErrLongPacket + } + + if int(length) > cap(b) { + // We know int(length) must be positive, because of tests above. + b = make([]byte, length) + } + + n, err := io.ReadFull(r, b[:length]) + return b[:n], err +} + +// ReadFrom provides a simple functional packet reader, +// using the given byte slice as a backing array. +// +// To protect against potential denial of service attacks, +// if the read packet length is longer than maxPacketLength, +// then no packet data will be read, and ErrLongPacket will be returned. +// (On 32-bit int architectures, all packets >= 2^31 in length +// will return ErrLongPacket regardless of maxPacketLength.) +// +// If the read packet length is longer than cap(b), +// then a throw-away slice will allocated to meet the exact packet length. +// This can be used to limit the length of reused buffers, +// while still allowing reception of occassional large packets. +// +// The Data field may alias the passed in byte slice, +// so the byte slice passed in should not be reused before RawPacket.Reset(). +func (p *RawPacket) ReadFrom(r io.Reader, b []byte, maxPacketLength uint32) error { + b, err := readPacket(r, b, maxPacketLength) + if err != nil { + return err + } + + return p.UnmarshalFrom(NewBuffer(b)) +} + +// RequestPacket implements the general packet format from draft-ietf-secsh-filexfer-02 +// but also automatically decode/encodes valid request packets (2 < type < 100 || type == 200). +// +// RequestPacket is intended for use in servers receiving requests, +// where any arbitrary request may be received, and so decoding them automatically +// is useful. +// +// For clients expecting to receive specific response packet types, +// where automatic unmarshaling of the packet body does not make sense, +// use RawPacket. +// +// Defined in https://tools.ietf.org/html/draft-ietf-secsh-filexfer-02#section-3 +type RequestPacket struct { + RequestID uint32 + + Request Packet +} + +// Reset clears the pointers and reference-semantic variables in RequestPacket, +// releasing underlying resources, and making them and the RequestPacket suitable to be reused, +// so long as no other references have been kept. +func (p *RequestPacket) Reset() { + p.Request = nil +} + +// MarshalPacket returns p as a two-part binary encoding of p. +// +// The internal p.RequestID is overridden by the reqid argument. +func (p *RequestPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + if p.Request == nil { + return nil, nil, errors.New("empty request packet") + } + + return p.Request.MarshalPacket(reqid, b) +} + +// MarshalBinary returns p as the binary encoding of p. +// +// This is a convenience implementation primarily intended for tests, +// because it is inefficient with allocations. +func (p *RequestPacket) MarshalBinary() ([]byte, error) { + return ComposePacket(p.MarshalPacket(p.RequestID, nil)) +} + +// UnmarshalFrom decodes a RequestPacket from the given Buffer into p. +// +// The Request field may alias the passed in Buffer, (e.g. SSH_FXP_WRITE), +// so the buffer passed in should not be reused before RequestPacket.Reset(). +func (p *RequestPacket) UnmarshalFrom(buf *Buffer) error { + typ, err := buf.ConsumeUint8() + if err != nil { + return err + } + + p.Request, err = newPacketFromType(PacketType(typ)) + if err != nil { + return err + } + + if p.RequestID, err = buf.ConsumeUint32(); err != nil { + return err + } + + return p.Request.UnmarshalPacketBody(buf) +} + +// UnmarshalBinary decodes a full request packet out of the given data. +// It is assumed that the uint32(length) has already been consumed to receive the data. +// +// This is a convenience implementation primarily intended for tests, +// because this must clone the given data byte slice, +// as Request is not allowed to alias any part of the data byte slice. +func (p *RequestPacket) UnmarshalBinary(data []byte) error { + clone := make([]byte, len(data)) + n := copy(clone, data) + return p.UnmarshalFrom(NewBuffer(clone[:n])) +} + +// ReadFrom provides a simple functional packet reader, +// using the given byte slice as a backing array. +// +// To protect against potential denial of service attacks, +// if the read packet length is longer than maxPacketLength, +// then no packet data will be read, and ErrLongPacket will be returned. +// (On 32-bit int architectures, all packets >= 2^31 in length +// will return ErrLongPacket regardless of maxPacketLength.) +// +// If the read packet length is longer than cap(b), +// then a throw-away slice will allocated to meet the exact packet length. +// This can be used to limit the length of reused buffers, +// while still allowing reception of occassional large packets. +// +// The Request field may alias the passed in byte slice, +// so the byte slice passed in should not be reused before RawPacket.Reset(). +func (p *RequestPacket) ReadFrom(r io.Reader, b []byte, maxPacketLength uint32) error { + b, err := readPacket(r, b, maxPacketLength) + if err != nil { + return err + } + + return p.UnmarshalFrom(NewBuffer(b)) +} diff --git a/internal/encoding/ssh/filexfer/packets_test.go b/internal/encoding/ssh/filexfer/packets_test.go new file mode 100644 index 00000000..8658e04d --- /dev/null +++ b/internal/encoding/ssh/filexfer/packets_test.go @@ -0,0 +1,132 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +func TestRawPacket(t *testing.T) { + const ( + id = 42 + errMsg = "eof" + langTag = "en" + ) + + p := &RawPacket{ + Type: PacketTypeStatus, + RequestID: id, + Data: Buffer{ + b: []byte{ + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 0x03, 'e', 'o', 'f', + 0x00, 0x00, 0x00, 0x02, 'e', 'n', + }, + }, + } + + data, err := p.MarshalBinary() + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 22, + 101, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 3, 'e', 'o', 'f', + 0x00, 0x00, 0x00, 2, 'e', 'n', + } + + if !bytes.Equal(data, want) { + t.Errorf("RawPacket.Marshal() = %X, but wanted %X", data, want) + } + + *p = RawPacket{} + + if err := p.ReadFrom(bytes.NewReader(data), nil, DefaultMaxPacketLength); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Type != PacketTypeStatus { + t.Errorf("RawPacket.UnmarshalBinary(): Type was %v, but expected %v", p.Type, PacketTypeStat) + } + + if p.RequestID != uint32(id) { + t.Errorf("RawPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id) + } + + want = []byte{ + 0x00, 0x00, 0x00, 0x01, + 0x00, 0x00, 0x00, 3, 'e', 'o', 'f', + 0x00, 0x00, 0x00, 2, 'e', 'n', + } + + if !bytes.Equal(p.Data.Bytes(), want) { + t.Fatalf("RawPacket.UnmarshalBinary(): Data was %X, but expected %X", p.Data, want) + } + + var resp StatusPacket + resp.UnmarshalPacketBody(&p.Data) + + if resp.StatusCode != StatusEOF { + t.Errorf("UnmarshalPacketBody(RawPacket.Data): StatusCode was %v, but expected %v", resp.StatusCode, StatusEOF) + } + + if resp.ErrorMessage != errMsg { + t.Errorf("UnmarshalPacketBody(RawPacket.Data): ErrorMessage was %q, but expected %q", resp.ErrorMessage, errMsg) + } + + if resp.LanguageTag != langTag { + t.Errorf("UnmarshalPacketBody(RawPacket.Data): LanguageTag was %q, but expected %q", resp.LanguageTag, langTag) + } +} + +func TestRequestPacket(t *testing.T) { + const ( + id = 42 + path = "foo" + ) + + p := &RequestPacket{ + RequestID: id, + Request: &StatPacket{ + Path: path, + }, + } + + data, err := p.MarshalBinary() + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 12, + 17, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 3, 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Errorf("RequestPacket.Marshal() = %X, but wanted %X", data, want) + } + + *p = RequestPacket{} + + if err := p.ReadFrom(bytes.NewReader(data), nil, DefaultMaxPacketLength); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.RequestID != uint32(id) { + t.Errorf("RequestPacket.UnmarshalBinary(): RequestID was %d, but expected %d", p.RequestID, id) + } + + req, ok := p.Request.(*StatPacket) + if !ok { + t.Fatalf("unexpected Request type was %T, but expected %T", p.Request, req) + } + + if req.Path != path { + t.Errorf("RequestPacket.UnmarshalBinary(): Request.Path was %q, but expected %q", req.Path, path) + } +} diff --git a/internal/encoding/ssh/filexfer/path_packets.go b/internal/encoding/ssh/filexfer/path_packets.go new file mode 100644 index 00000000..f7085f37 --- /dev/null +++ b/internal/encoding/ssh/filexfer/path_packets.go @@ -0,0 +1,318 @@ +package filexfer + +// LStatPacket defines the SSH_FXP_LSTAT packet. +type LStatPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *LStatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeLStat, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *LStatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// SetstatPacket defines the SSH_FXP_SETSTAT packet. +type SetstatPacket struct { + Path string + Attrs Attributes +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *SetstatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) + p.Attrs.Len() // string(path) + ATTRS(attrs) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeSetstat, reqid) + buf.AppendString(p.Path) + + p.Attrs.MarshalInto(buf) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *SetstatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return p.Attrs.UnmarshalFrom(buf) +} + +// RemovePacket defines the SSH_FXP_REMOVE packet. +type RemovePacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *RemovePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeRemove, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *RemovePacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// MkdirPacket defines the SSH_FXP_MKDIR packet. +type MkdirPacket struct { + Path string + Attrs Attributes +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *MkdirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) + p.Attrs.Len() // string(path) + ATTRS(attrs) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeMkdir, reqid) + buf.AppendString(p.Path) + + p.Attrs.MarshalInto(buf) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *MkdirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return p.Attrs.UnmarshalFrom(buf) +} + +// RmdirPacket defines the SSH_FXP_RMDIR packet. +type RmdirPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *RmdirPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeRmdir, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *RmdirPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// RealPathPacket defines the SSH_FXP_REALPATH packet. +type RealPathPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *RealPathPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeRealPath, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *RealPathPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// StatPacket defines the SSH_FXP_STAT packet. +type StatPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *StatPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeStat, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *StatPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// RenamePacket defines the SSH_FXP_RENAME packet. +type RenamePacket struct { + OldPath string + NewPath string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *RenamePacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + // string(oldpath) + string(newpath) + size := 4 + len(p.OldPath) + 4 + len(p.NewPath) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeRename, reqid) + buf.AppendString(p.OldPath) + buf.AppendString(p.NewPath) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *RenamePacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.OldPath, err = buf.ConsumeString(); err != nil { + return err + } + + if p.NewPath, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// ReadLinkPacket defines the SSH_FXP_READLINK packet. +type ReadLinkPacket struct { + Path string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *ReadLinkPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + size := 4 + len(p.Path) // string(path) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeReadLink, reqid) + buf.AppendString(p.Path) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *ReadLinkPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + if p.Path, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} + +// SymlinkPacket defines the SSH_FXP_SYMLINK packet. +// +// The order of the arguments to the SSH_FXP_SYMLINK method was inadvertently reversed. +// Unfortunately, the reversal was not noticed until the server was widely deployed. +// Covered in Section 3.1 of https://github.com/openssh/openssh-portable/blob/master/PROTOCOL +type SymlinkPacket struct { + LinkPath string + TargetPath string +} + +// MarshalPacket returns p as a two-part binary encoding of p. +func (p *SymlinkPacket) MarshalPacket(reqid uint32, b []byte) (header, payload []byte, err error) { + buf := NewBuffer(b) + if buf.Cap() < 9 { + // string(targetpath) + string(linkpath) + size := 4 + len(p.TargetPath) + 4 + len(p.LinkPath) + buf = NewMarshalBuffer(size) + } + + buf.StartPacket(PacketTypeSymlink, reqid) + + // Arguments were inadvertently reversed. + buf.AppendString(p.TargetPath) + buf.AppendString(p.LinkPath) + + return buf.Packet(payload) +} + +// UnmarshalPacketBody unmarshals the packet body from the given Buffer. +// It is assumed that the uint32(request-id) has already been consumed. +func (p *SymlinkPacket) UnmarshalPacketBody(buf *Buffer) (err error) { + // Arguments were inadvertently reversed. + if p.TargetPath, err = buf.ConsumeString(); err != nil { + return err + } + + if p.LinkPath, err = buf.ConsumeString(); err != nil { + return err + } + + return nil +} diff --git a/internal/encoding/ssh/filexfer/path_packets_test.go b/internal/encoding/ssh/filexfer/path_packets_test.go new file mode 100644 index 00000000..852145ed --- /dev/null +++ b/internal/encoding/ssh/filexfer/path_packets_test.go @@ -0,0 +1,450 @@ +package filexfer + +import ( + "bytes" + "testing" +) + +var _ Packet = &LStatPacket{} + +func TestLStatPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &LStatPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 7, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = LStatPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &SetstatPacket{} + +func TestSetstatPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + perms = 0x87654321 + ) + + p := &SetstatPacket{ + Path: "/foo", + Attrs: Attributes{ + Flags: AttrPermissions, + Permissions: perms, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 21, + 9, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = SetstatPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } + + if p.Attrs.Flags != AttrPermissions { + t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions) + } + + if p.Attrs.Permissions != perms { + t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms) + } +} + +var _ Packet = &RemovePacket{} + +func TestRemovePacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &RemovePacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 13, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = RemovePacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &MkdirPacket{} + +func TestMkdirPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + perms = 0x87654321 + ) + + p := &MkdirPacket{ + Path: "/foo", + Attrs: Attributes{ + Flags: AttrPermissions, + Permissions: perms, + }, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 21, + 14, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + 0x00, 0x00, 0x00, 0x04, + 0x87, 0x65, 0x43, 0x21, + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = MkdirPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } + + if p.Attrs.Flags != AttrPermissions { + t.Errorf("UnmarshalPacketBody(): Attrs.Flags was %#x, but expected %#x", p.Attrs.Flags, AttrPermissions) + } + + if p.Attrs.Permissions != perms { + t.Errorf("UnmarshalPacketBody(): Attrs.Permissions was %#v, but expected %#v", p.Attrs.Permissions, perms) + } +} + +var _ Packet = &RmdirPacket{} + +func TestRmdirPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &RmdirPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 15, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = RmdirPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &RealPathPacket{} + +func TestRealPathPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &RealPathPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 16, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = RealPathPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &StatPacket{} + +func TestStatPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &StatPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 17, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = StatPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &RenamePacket{} + +func TestRenamePacket(t *testing.T) { + const ( + id = 42 + oldpath = "/foo" + newpath = "/bar" + ) + + p := &RenamePacket{ + OldPath: oldpath, + NewPath: newpath, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 21, + 18, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = RenamePacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.OldPath != oldpath { + t.Errorf("UnmarshalPacketBody(): OldPath was %q, but expected %q", p.OldPath, oldpath) + } + + if p.NewPath != newpath { + t.Errorf("UnmarshalPacketBody(): NewPath was %q, but expected %q", p.NewPath, newpath) + } +} + +var _ Packet = &ReadLinkPacket{} + +func TestReadLinkPacket(t *testing.T) { + const ( + id = 42 + path = "/foo" + ) + + p := &ReadLinkPacket{ + Path: path, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 13, + 19, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = ReadLinkPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.Path != path { + t.Errorf("UnmarshalPacketBody(): Path was %q, but expected %q", p.Path, path) + } +} + +var _ Packet = &SymlinkPacket{} + +func TestSymlinkPacket(t *testing.T) { + const ( + id = 42 + linkpath = "/foo" + targetpath = "/bar" + ) + + p := &SymlinkPacket{ + LinkPath: linkpath, + TargetPath: targetpath, + } + + data, err := ComposePacket(p.MarshalPacket(id, nil)) + if err != nil { + t.Fatal("unexpected error:", err) + } + + want := []byte{ + 0x00, 0x00, 0x00, 21, + 20, + 0x00, 0x00, 0x00, 42, + 0x00, 0x00, 0x00, 4, '/', 'b', 'a', 'r', // Arguments were inadvertently reversed. + 0x00, 0x00, 0x00, 4, '/', 'f', 'o', 'o', + } + + if !bytes.Equal(data, want) { + t.Fatalf("Marshal() = %X, but wanted %X", data, want) + } + + *p = SymlinkPacket{} + + // UnmarshalPacketBody assumes the (length, type, request-id) have already been consumed. + if err := p.UnmarshalPacketBody(NewBuffer(data[9:])); err != nil { + t.Fatal("unexpected error:", err) + } + + if p.LinkPath != linkpath { + t.Errorf("UnmarshalPacketBody(): LinkPath was %q, but expected %q", p.LinkPath, linkpath) + } + + if p.TargetPath != targetpath { + t.Errorf("UnmarshalPacketBody(): TargetPath was %q, but expected %q", p.TargetPath, targetpath) + } +} diff --git a/internal/encoding/ssh/filexfer/permissions.go b/internal/encoding/ssh/filexfer/permissions.go new file mode 100644 index 00000000..c5050baf --- /dev/null +++ b/internal/encoding/ssh/filexfer/permissions.go @@ -0,0 +1,90 @@ +package filexfer + +// FileMode represents a file’s mode and permission bits. +// The bits are defined according to POSIX standards, +// and may not apply to the OS being built for. +type FileMode uint32 + +// Permission flags, defined here to avoid potential inconsistencies in individual OS implementations. +const ( + ModePerm FileMode = 0o0777 // S_IRWXU | S_IRWXG | S_IRWXO + ModeUserRead FileMode = 0o0400 // S_IRUSR + ModeUserWrite FileMode = 0o0200 // S_IWUSR + ModeUserExec FileMode = 0o0100 // S_IXUSR + ModeGroupRead FileMode = 0o0040 // S_IRGRP + ModeGroupWrite FileMode = 0o0020 // S_IWGRP + ModeGroupExec FileMode = 0o0010 // S_IXGRP + ModeOtherRead FileMode = 0o0004 // S_IROTH + ModeOtherWrite FileMode = 0o0002 // S_IWOTH + ModeOtherExec FileMode = 0o0001 // S_IXOTH + + ModeSetUID FileMode = 0o4000 // S_ISUID + ModeSetGID FileMode = 0o2000 // S_ISGID + ModeSticky FileMode = 0o1000 // S_ISVTX + + ModeType FileMode = 0xF000 // S_IFMT + ModeNamedPipe FileMode = 0x1000 // S_IFIFO + ModeCharDevice FileMode = 0x2000 // S_IFCHR + ModeDir FileMode = 0x4000 // S_IFDIR + ModeDevice FileMode = 0x6000 // S_IFBLK + ModeRegular FileMode = 0x8000 // S_IFREG + ModeSymlink FileMode = 0xA000 // S_IFLNK + ModeSocket FileMode = 0xC000 // S_IFSOCK +) + +// IsDir reports whether m describes a directory. +// That is, it tests for m.Type() == ModeDir. +func (m FileMode) IsDir() bool { + return (m & ModeType) == ModeDir +} + +// IsRegular reports whether m describes a regular file. +// That is, it tests for m.Type() == ModeRegular +func (m FileMode) IsRegular() bool { + return (m & ModeType) == ModeRegular +} + +// Perm returns the POSIX permission bits in m (m & ModePerm). +func (m FileMode) Perm() FileMode { + return (m & ModePerm) +} + +// Type returns the type bits in m (m & ModeType). +func (m FileMode) Type() FileMode { + return (m & ModeType) +} + +// String returns a `-rwxrwxrwx` style string representing the `ls -l` POSIX permissions string. +func (m FileMode) String() string { + var buf [10]byte + + switch m.Type() { + case ModeRegular: + buf[0] = '-' + case ModeDir: + buf[0] = 'd' + case ModeSymlink: + buf[0] = 'l' + case ModeDevice: + buf[0] = 'b' + case ModeCharDevice: + buf[0] = 'c' + case ModeNamedPipe: + buf[0] = 'p' + case ModeSocket: + buf[0] = 's' + default: + buf[0] = '?' + } + + const rwx = "rwxrwxrwx" + for i, c := range rwx { + if m&(1<