Skip to content

Commit 3de0302

Browse files
Added SFTP file transfer resume support on both PUT and GET. (#775)
* Added SFTP file transfer resume support on both PUT and GET. Internally SFTPFileTransfer has a few sanity checks to fall back to full replacement even if the resume flag is set. SCP file transfers have not been changed to support this at this time. * Added JUnit tests for issue-700 * Throw SCPException when attempting to resume SCP transfers. * Licensing * Small bug resuming a completed file was restarting since the bytes were equal. * Enhanced test cases to validate the expected bytes transferred for each scenario are the actual bytes transferred. * Removed author info which was pre-filled from company IDE template * Added "fall through" comment for switch * Changed the API for requesting a resume from a boolean flag with some internal decisions to be a user-specified long byte offset. This is cleaner but puts the onus on the caller to know exactly what they're asking for in their circumstance, which is ultimately better for a library like sshj. * Reverted some now-unnecessary changes to SFTPFileTransfer.Uploader.prepareFile() * Fix gradle exclude path for test files Co-authored-by: Jeroen van Erp <[email protected]>
1 parent d7e402c commit 3de0302

File tree

12 files changed

+398
-27
lines changed

12 files changed

+398
-27
lines changed

build.gradle

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -65,7 +65,12 @@ license {
6565
mapping {
6666
java = 'SLASHSTAR_STYLE'
6767
}
68-
excludes(['**/djb/Curve25519.java', '**/sshj/common/Base64.java', '**/com/hierynomus/sshj/userauth/keyprovider/bcrypt/*.java'])
68+
excludes([
69+
'**/djb/Curve25519.java',
70+
'**/sshj/common/Base64.java',
71+
'**/com/hierynomus/sshj/userauth/keyprovider/bcrypt/*.java',
72+
'**/files/test_file_*.txt',
73+
])
6974
}
7075

7176
if (!JavaVersion.current().isJava9Compatible()) {

src/main/java/net/schmizz/sshj/sftp/RemoteFile.java

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -261,7 +261,16 @@ public int getLength() {
261261
private boolean eof;
262262

263263
public ReadAheadRemoteFileInputStream(int maxUnconfirmedReads) {
264-
this(maxUnconfirmedReads, 0L, -1L);
264+
this(maxUnconfirmedReads, 0L);
265+
}
266+
267+
/**
268+
*
269+
* @param maxUnconfirmedReads Maximum number of unconfirmed requests to send
270+
* @param fileOffset Initial offset in file to read from
271+
*/
272+
public ReadAheadRemoteFileInputStream(int maxUnconfirmedReads, long fileOffset) {
273+
this(maxUnconfirmedReads, fileOffset, -1L);
265274
}
266275

267276
/**

src/main/java/net/schmizz/sshj/sftp/SFTPClient.java

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,21 +232,41 @@ public void get(String source, String dest)
232232
throws IOException {
233233
xfer.download(source, dest);
234234
}
235+
236+
public void get(String source, String dest, long byteOffset)
237+
throws IOException {
238+
xfer.download(source, dest, byteOffset);
239+
}
235240

236241
public void put(String source, String dest)
237242
throws IOException {
238243
xfer.upload(source, dest);
239244
}
240245

246+
public void put(String source, String dest, long byteOffset)
247+
throws IOException {
248+
xfer.upload(source, dest, byteOffset);
249+
}
250+
241251
public void get(String source, LocalDestFile dest)
242252
throws IOException {
243253
xfer.download(source, dest);
244254
}
255+
256+
public void get(String source, LocalDestFile dest, long byteOffset)
257+
throws IOException {
258+
xfer.download(source, dest, byteOffset);
259+
}
245260

246261
public void put(LocalSourceFile source, String dest)
247262
throws IOException {
248263
xfer.upload(source, dest);
249264
}
265+
266+
public void put(LocalSourceFile source, String dest, long byteOffset)
267+
throws IOException {
268+
xfer.upload(source, dest, byteOffset);
269+
}
250270

251271
@Override
252272
public void close()

src/main/java/net/schmizz/sshj/sftp/SFTPFileTransfer.java

Lines changed: 61 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -50,25 +50,47 @@ public void setPreserveAttributes(boolean preserveAttributes) {
5050
@Override
5151
public void upload(String source, String dest)
5252
throws IOException {
53-
upload(new FileSystemFile(source), dest);
53+
upload(source, dest, 0);
54+
}
55+
56+
@Override
57+
public void upload(String source, String dest, long byteOffset)
58+
throws IOException {
59+
upload(new FileSystemFile(source), dest, byteOffset);
5460
}
5561

5662
@Override
5763
public void download(String source, String dest)
5864
throws IOException {
59-
download(source, new FileSystemFile(dest));
65+
download(source, dest, 0);
66+
}
67+
68+
@Override
69+
public void download(String source, String dest, long byteOffset)
70+
throws IOException {
71+
download(source, new FileSystemFile(dest), byteOffset);
6072
}
6173

6274
@Override
6375
public void upload(LocalSourceFile localFile, String remotePath) throws IOException {
64-
new Uploader(localFile, remotePath).upload(getTransferListener());
76+
upload(localFile, remotePath, 0);
77+
}
78+
79+
@Override
80+
public void upload(LocalSourceFile localFile, String remotePath, long byteOffset) throws IOException {
81+
new Uploader(localFile, remotePath).upload(getTransferListener(), byteOffset);
6582
}
6683

6784
@Override
6885
public void download(String source, LocalDestFile dest) throws IOException {
86+
download(source, dest, 0);
87+
}
88+
89+
@Override
90+
public void download(String source, LocalDestFile dest, long byteOffset) throws IOException {
6991
final PathComponents pathComponents = engine.getPathHelper().getComponents(source);
7092
final FileAttributes attributes = engine.stat(source);
71-
new Downloader().download(getTransferListener(), new RemoteResourceInfo(pathComponents, attributes), dest);
93+
new Downloader().download(getTransferListener(), new RemoteResourceInfo(pathComponents, attributes), dest, byteOffset);
7294
}
7395

7496
public void setUploadFilter(LocalFileFilter uploadFilter) {
@@ -92,7 +114,8 @@ private class Downloader {
92114
@SuppressWarnings("PMD.MissingBreakInSwitch")
93115
private void download(final TransferListener listener,
94116
final RemoteResourceInfo remote,
95-
final LocalDestFile local) throws IOException {
117+
final LocalDestFile local,
118+
final long byteOffset) throws IOException {
96119
final LocalDestFile adjustedFile;
97120
switch (remote.getAttributes().getType()) {
98121
case DIRECTORY:
@@ -101,8 +124,9 @@ private void download(final TransferListener listener,
101124
case UNKNOWN:
102125
log.warn("Server did not supply information about the type of file at `{}` " +
103126
"-- assuming it is a regular file!", remote.getPath());
127+
// fall through
104128
case REGULAR:
105-
adjustedFile = downloadFile(listener.file(remote.getName(), remote.getAttributes().getSize()), remote, local);
129+
adjustedFile = downloadFile(listener.file(remote.getName(), remote.getAttributes().getSize()), remote, local, byteOffset);
106130
break;
107131
default:
108132
throw new IOException(remote + " is not a regular file or directory");
@@ -119,7 +143,7 @@ private LocalDestFile downloadDir(final TransferListener listener,
119143
final RemoteDirectory rd = engine.openDir(remote.getPath());
120144
try {
121145
for (RemoteResourceInfo rri : rd.scan(getDownloadFilter()))
122-
download(listener, rri, adjusted.getChild(rri.getName()));
146+
download(listener, rri, adjusted.getChild(rri.getName()), 0); // not supporting individual byte offsets for these files
123147
} finally {
124148
rd.close();
125149
}
@@ -128,13 +152,15 @@ private LocalDestFile downloadDir(final TransferListener listener,
128152

129153
private LocalDestFile downloadFile(final StreamCopier.Listener listener,
130154
final RemoteResourceInfo remote,
131-
final LocalDestFile local)
155+
final LocalDestFile local,
156+
final long byteOffset)
132157
throws IOException {
133158
final LocalDestFile adjusted = local.getTargetFile(remote.getName());
134159
final RemoteFile rf = engine.open(remote.getPath());
135160
try {
136-
final RemoteFile.ReadAheadRemoteFileInputStream rfis = rf.new ReadAheadRemoteFileInputStream(16);
137-
final OutputStream os = adjusted.getOutputStream();
161+
log.debug("Attempting to download {} with offset={}", remote.getPath(), byteOffset);
162+
final RemoteFile.ReadAheadRemoteFileInputStream rfis = rf.new ReadAheadRemoteFileInputStream(16, byteOffset);
163+
final OutputStream os = adjusted.getOutputStream(byteOffset != 0);
138164
try {
139165
new StreamCopier(rfis, os, engine.getLoggerFactory())
140166
.bufSize(engine.getSubsystem().getLocalMaxPacketSize())
@@ -173,17 +199,17 @@ private Uploader(final LocalSourceFile source, final String remote) {
173199
this.remote = remote;
174200
}
175201

176-
private void upload(final TransferListener listener) throws IOException {
202+
private void upload(final TransferListener listener, long byteOffset) throws IOException {
177203
if (source.isDirectory()) {
178204
makeDirIfNotExists(remote); // Ensure that the directory exists
179205
uploadDir(listener.directory(source.getName()), source, remote);
180206
setAttributes(source, remote);
181207
} else if (source.isFile() && isDirectory(remote)) {
182208
String adjustedRemote = engine.getPathHelper().adjustForParent(this.remote, source.getName());
183-
uploadFile(listener.file(source.getName(), source.getLength()), source, adjustedRemote);
209+
uploadFile(listener.file(source.getName(), source.getLength()), source, adjustedRemote, byteOffset);
184210
setAttributes(source, adjustedRemote);
185211
} else if (source.isFile()) {
186-
uploadFile(listener.file(source.getName(), source.getLength()), source, remote);
212+
uploadFile(listener.file(source.getName(), source.getLength()), source, remote, byteOffset);
187213
setAttributes(source, remote);
188214
} else {
189215
throw new IOException(source + " is not a file or directory");
@@ -192,13 +218,14 @@ private void upload(final TransferListener listener) throws IOException {
192218

193219
private void upload(final TransferListener listener,
194220
final LocalSourceFile local,
195-
final String remote)
221+
final String remote,
222+
final long byteOffset)
196223
throws IOException {
197224
final String adjustedPath;
198225
if (local.isDirectory()) {
199226
adjustedPath = uploadDir(listener.directory(local.getName()), local, remote);
200227
} else if (local.isFile()) {
201-
adjustedPath = uploadFile(listener.file(local.getName(), local.getLength()), local, remote);
228+
adjustedPath = uploadFile(listener.file(local.getName(), local.getLength()), local, remote, byteOffset);
202229
} else {
203230
throw new IOException(local + " is not a file or directory");
204231
}
@@ -217,22 +244,34 @@ private String uploadDir(final TransferListener listener,
217244
throws IOException {
218245
makeDirIfNotExists(remote);
219246
for (LocalSourceFile f : local.getChildren(getUploadFilter()))
220-
upload(listener, f, engine.getPathHelper().adjustForParent(remote, f.getName()));
247+
upload(listener, f, engine.getPathHelper().adjustForParent(remote, f.getName()), 0); // not supporting individual byte offsets for these files
221248
return remote;
222249
}
223250

224251
private String uploadFile(final StreamCopier.Listener listener,
225252
final LocalSourceFile local,
226-
final String remote)
253+
final String remote,
254+
final long byteOffset)
227255
throws IOException {
228-
final String adjusted = prepareFile(local, remote);
256+
final String adjusted = prepareFile(local, remote, byteOffset);
229257
RemoteFile rf = null;
230258
InputStream fis = null;
231259
RemoteFile.RemoteFileOutputStream rfos = null;
260+
EnumSet<OpenMode> modes;
232261
try {
233-
rf = engine.open(adjusted, EnumSet.of(OpenMode.WRITE, OpenMode.CREAT, OpenMode.TRUNC));
262+
if (byteOffset == 0) {
263+
// Starting at the beginning, overwrite/create
264+
modes = EnumSet.of(OpenMode.WRITE, OpenMode.CREAT, OpenMode.TRUNC);
265+
} else {
266+
// Starting at some offset, append
267+
modes = EnumSet.of(OpenMode.WRITE, OpenMode.APPEND);
268+
}
269+
270+
log.debug("Attempting to upload {} with offset={}", local.getName(), byteOffset);
271+
rf = engine.open(adjusted, modes);
234272
fis = local.getInputStream();
235-
rfos = rf.new RemoteFileOutputStream(0, 16);
273+
fis.skip(byteOffset);
274+
rfos = rf.new RemoteFileOutputStream(byteOffset, 16);
236275
new StreamCopier(fis, rfos, engine.getLoggerFactory())
237276
.bufSize(engine.getSubsystem().getRemoteMaxPacketSize() - rf.getOutgoingPacketOverhead())
238277
.keepFlushing(false)
@@ -294,7 +333,7 @@ private boolean isDirectory(final String remote) throws IOException {
294333
}
295334
}
296335

297-
private String prepareFile(final LocalSourceFile local, final String remote)
336+
private String prepareFile(final LocalSourceFile local, final String remote, final long byteOffset)
298337
throws IOException {
299338
final FileAttributes attrs;
300339
try {
@@ -309,7 +348,7 @@ private String prepareFile(final LocalSourceFile local, final String remote)
309348
if (attrs.getMode().getType() == FileMode.Type.DIRECTORY) {
310349
throw new IOException("Trying to upload file " + local.getName() + " to path " + remote + " but that is a directory");
311350
} else {
312-
log.debug("probeFile: {} is a {} file that will be replaced", remote, attrs.getMode().getType());
351+
log.debug("probeFile: {} is a {} file that will be {}", remote, attrs.getMode().getType(), byteOffset > 0 ? "resumed" : "replaced");
313352
return remote;
314353
}
315354
}

src/main/java/net/schmizz/sshj/xfer/FileSystemFile.java

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -71,7 +71,13 @@ public InputStream getInputStream()
7171
@Override
7272
public OutputStream getOutputStream()
7373
throws IOException {
74-
return new FileOutputStream(file);
74+
return getOutputStream(false);
75+
}
76+
77+
@Override
78+
public OutputStream getOutputStream(boolean append)
79+
throws IOException {
80+
return new FileOutputStream(file, append);
7581
}
7682

7783
@Override

src/main/java/net/schmizz/sshj/xfer/FileTransfer.java

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,6 +31,19 @@ public interface FileTransfer {
3131
void upload(String localPath, String remotePath)
3232
throws IOException;
3333

34+
/**
35+
* This is meant to delegate to {@link #upload(LocalSourceFile, String)} with the {@code localPath} wrapped as e.g.
36+
* a {@link FileSystemFile}. Appends to existing if {@code byteOffset} &gt; 0.
37+
*
38+
* @param localPath
39+
* @param remotePath
40+
* @param byteOffset
41+
*
42+
* @throws IOException
43+
*/
44+
void upload(String localPath, String remotePath, long byteOffset)
45+
throws IOException;
46+
3447
/**
3548
* This is meant to delegate to {@link #download(String, LocalDestFile)} with the {@code localPath} wrapped as e.g.
3649
* a {@link FileSystemFile}.
@@ -43,6 +56,19 @@ void upload(String localPath, String remotePath)
4356
void download(String remotePath, String localPath)
4457
throws IOException;
4558

59+
/**
60+
* This is meant to delegate to {@link #download(String, LocalDestFile)} with the {@code localPath} wrapped as e.g.
61+
* a {@link FileSystemFile}. Appends to existing if {@code byteOffset} &gt; 0.
62+
*
63+
* @param localPath
64+
* @param remotePath
65+
* @param byteOffset
66+
*
67+
* @throws IOException
68+
*/
69+
void download(String remotePath, String localPath, long byteOffset)
70+
throws IOException;
71+
4672
/**
4773
* Upload {@code localFile} to {@code remotePath}.
4874
*
@@ -54,6 +80,18 @@ void download(String remotePath, String localPath)
5480
void upload(LocalSourceFile localFile, String remotePath)
5581
throws IOException;
5682

83+
/**
84+
* Upload {@code localFile} to {@code remotePath}. Appends to existing if {@code byteOffset} &gt; 0.
85+
*
86+
* @param localFile
87+
* @param remotePath
88+
* @param byteOffset
89+
*
90+
* @throws IOException
91+
*/
92+
void upload(LocalSourceFile localFile, String remotePath, long byteOffset)
93+
throws IOException;
94+
5795
/**
5896
* Download {@code remotePath} to {@code localFile}.
5997
*
@@ -65,6 +103,18 @@ void upload(LocalSourceFile localFile, String remotePath)
65103
void download(String remotePath, LocalDestFile localFile)
66104
throws IOException;
67105

106+
/**
107+
* Download {@code remotePath} to {@code localFile}. Appends to existing if {@code byteOffset} &gt; 0.
108+
*
109+
* @param localFile
110+
* @param remotePath
111+
* @param byteOffset
112+
*
113+
* @throws IOException
114+
*/
115+
void download(String remotePath, LocalDestFile localFile, long byteOffset)
116+
throws IOException;
117+
68118
TransferListener getTransferListener();
69119

70120
void setTransferListener(TransferListener listener);

src/main/java/net/schmizz/sshj/xfer/LocalDestFile.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,13 @@
2020

2121
public interface LocalDestFile {
2222

23+
long getLength();
24+
2325
OutputStream getOutputStream()
2426
throws IOException;
27+
28+
OutputStream getOutputStream(boolean append)
29+
throws IOException;
2530

2631
/** @return A child file/directory of this directory with given {@code name}. */
2732
LocalDestFile getChild(String name);

0 commit comments

Comments
 (0)