Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 19 additions & 2 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -1513,7 +1513,6 @@ func (f *File) WriteTo(w io.Writer) (written int64, err error) {
var n int

s := <-readWork.res
resPool.Put(readWork.res)

err := s.err
if err == nil {
Expand All @@ -1528,8 +1527,24 @@ func (f *File) WriteTo(w io.Writer) (written int64, err error) {

} else {
l, data := unmarshalUint32(data)
b = pool.Get()[:l]
b = pool.Get()
n = copy(b, data[:l])

// The dispatcher already queued the next read at
// off+chunkSize, so a short read here (a server is
// allowed to return fewer bytes than requested, e.g.
// when it clamps to a smaller max packet size) would
// leave a hole. Fill the rest of the chunk before
// handing it on, the same way the sequential path
// does, or we silently drop the skipped bytes. A
// genuine EOF ends the fill and is carried as the
// chunk's error.
if n < chunkSize {
var m int
m, err = f.readChunkAt(readWork.res, b[n:], readWork.off+int64(n))

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The problem is this is that we’ve already dispatched reads to later offsets, catch up reads like this result in non-sequential access, which has been a result of various bugs here and there.

Namely, some ssh implementation automatically delete a file once the whole file has been read the first time, this backfilling catch up would break under this situation.

Also, backing up and backfilling can cause significant performance degradation as servers are tuned for sequential read access, not really for random access.

n += m
}

b = b[:n]
}

Expand All @@ -1538,6 +1553,8 @@ func (f *File) WriteTo(w io.Writer) (written int64, err error) {
}
}

resPool.Put(readWork.res)

writeWork := writeWork{
b: b,
off: readWork.off,
Expand Down
65 changes: 65 additions & 0 deletions client_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -198,3 +198,68 @@ func TestClientNoSid(t *testing.T) {
t.Fatal("expected ErrSSHFxConnectionLost, got", err)
}
}

// Issue #658: the concurrent File.WriteTo path (used by io.Copy) must not
// silently drop data when the server returns short reads. A server is free to
// return fewer bytes than asked for, which it will whenever the client's max
// packet size is larger than the server's.
func TestClientWriteToShortReads(t *testing.T) {
cr, sw := io.Pipe()
sr, cw := io.Pipe()

// The default server max packet size is 32768, so a bigger client packet
// size makes every read come back short.
server, err := NewServer(struct {
io.Reader
io.WriteCloser
}{sr, sw})
if err != nil {
t.Fatal(err)
}
go server.Serve()

client, err := NewClientPipe(cr, cw, MaxPacketUnchecked(128*1024))
if err != nil {
t.Fatal(err)
}
// Close the client first (LIFO), so its receive loop sees the server go away.
defer client.Close()
defer server.Close()

// Bigger than the client packet size so WriteTo takes the concurrent path,
// and not a multiple of it so the last chunk is partial as well.
want := make([]byte, 5*128*1024+123)
for i := range want {
want[i] = byte(i)
}

tmp, err := os.CreateTemp("", "sftp-writeto-shortread")
if err != nil {
t.Fatal(err)
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(want); err != nil {
t.Fatal(err)
}
if err := tmp.Close(); err != nil {
t.Fatal(err)
}

f, err := client.Open(tmp.Name())
if err != nil {
t.Fatal(err)
}
defer f.Close()

var buf bytes.Buffer
n, err := f.WriteTo(&buf)
if err != nil {
t.Fatalf("WriteTo: %v", err)
}
if n != int64(len(want)) {
t.Errorf("WriteTo returned %d, want %d", n, len(want))
}
if !bytes.Equal(buf.Bytes(), want) {
t.Errorf("WriteTo produced %d bytes that do not match the source (want %d)", buf.Len(), len(want))
}
}