diff --git a/client.go b/client.go index c04f1afd..67cf5145 100644 --- a/client.go +++ b/client.go @@ -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 { @@ -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)) + n += m + } + b = b[:n] } @@ -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, diff --git a/client_test.go b/client_test.go index dda8af2b..332a4875 100644 --- a/client_test.go +++ b/client_test.go @@ -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)) + } +}