This is an automated email from the ASF dual-hosted git repository.
hansva pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/hop.git
The following commit(s) were added to refs/heads/main by this push:
new ac675e4432 SFTP slashes for z/Os, improved error messages #9033 (#9043)
ac675e4432 is described below
commit ac675e443263798a4079f26f0219ad0758f3f4ad
Author: Bart Maertens <[email protected]>
AuthorDate: Sat Oct 24 09:40:29 2026 +0200
SFTP slashes for z/Os, improved error messages #9033 (#9043)
* Issue #9033 : Keep the second slash of a single-level z/OS data set path
in SFTP
* Issue #9033 : SFTP Put reports a target that isn't a file, and the root
cause of errors
* Issue #9033 : Keep the second slash of single-level data set paths in
SftpClient too
* Issue #9033 : Only explain z/OS data sets for data set targets, translate
the root cause label
---
.../hop/pipeline/transforms/sftpput/SftpPut.java | 50 +++++++-
.../org/apache/hop/vfs/sftp/client/SftpClient.java | 13 +-
.../hop/vfs/sftp/provider/SftpFileNameParser.java | 25 ++++
.../hop/vfs/sftp/provider/SftpFileObject.java | 18 ++-
.../sftpput/messages/messages_en_US.properties | 3 +
.../pipeline/transforms/sftpput/SftpPutTest.java | 72 +++++++++++
.../vfs/sftp/provider/SftpDoubleSlashPathTest.java | 140 +++++++++++++++++++++
7 files changed, 308 insertions(+), 13 deletions(-)
diff --git
a/plugins/tech/sftp/src/main/java/org/apache/hop/pipeline/transforms/sftpput/SftpPut.java
b/plugins/tech/sftp/src/main/java/org/apache/hop/pipeline/transforms/sftpput/SftpPut.java
index 29ce23bf3e..6acd768cca 100644
---
a/plugins/tech/sftp/src/main/java/org/apache/hop/pipeline/transforms/sftpput/SftpPut.java
+++
b/plugins/tech/sftp/src/main/java/org/apache/hop/pipeline/transforms/sftpput/SftpPut.java
@@ -19,6 +19,7 @@ package org.apache.hop.pipeline.transforms.sftpput;
import java.io.ByteArrayInputStream;
import java.io.InputStream;
import java.io.OutputStream;
+import org.apache.commons.lang3.exception.ExceptionUtils;
import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.NameScope;
@@ -36,6 +37,7 @@ import org.apache.hop.pipeline.transform.TransformMeta;
import org.apache.hop.pipeline.transforms.sftpput.SftpPutMeta.AfterSftpPut;
import org.apache.hop.vfs.sftp.SftpConnections;
import org.apache.hop.vfs.sftp.metadata.SftpConnection;
+import org.apache.hop.vfs.sftp.provider.IRootPrefixFileName;
/** Uploads files or field content to the server of a named SFTP connection. */
public class SftpPut extends BaseTransform<SftpPutMeta, SftpPutData> {
@@ -110,9 +112,9 @@ public class SftpPut extends BaseTransform<SftpPutMeta,
SftpPutData> {
if (isDebug()) {
logDebug(
BaseMessages.getString(
- PKG, "SftpPut.Log.RowSentToErrorStream", getLinesRead(),
e.getMessage()));
+ PKG, "SftpPut.Log.RowSentToErrorStream", getLinesRead(),
errorDescription(e)));
}
- putError(data.inputRowMeta, row, 1, e.toString(), null, "SFTPPUT001");
+ putError(data.inputRowMeta, row, 1, errorDescription(e), null,
"SFTPPUT001");
} else {
logError(BaseMessages.getString(PKG, "SftpPut.Log.ErrorInTransform"),
e);
setErrors(1);
@@ -243,9 +245,11 @@ public class SftpPut extends BaseTransform<SftpPutMeta,
SftpPutData> {
long startTime = System.currentTimeMillis();
long bytes;
- try (FileObject targetFile = HopVfs.getFileObject(targetUri, this);
- OutputStream outputStream = HopVfs.getOutputStream(targetFile,
false)) {
- bytes = inputStream.transferTo(outputStream);
+ try (FileObject targetFile = HopVfs.getFileObject(targetUri, this)) {
+ checkTargetIsAFile(targetFile, targetUri);
+ try (OutputStream outputStream = HopVfs.getOutputStream(targetFile,
false)) {
+ bytes = inputStream.transferTo(outputStream);
+ }
}
data.uploadedFiles++;
data.uploadedBytes += bytes;
@@ -267,6 +271,42 @@ public class SftpPut extends BaseTransform<SftpPutMeta,
SftpPutData> {
}
}
+ /**
+ * Something that is there but isn't a file can't be overwritten. Left to
VFS, that ends in a bare
+ * "Could not create file" - and on the way there in an attempt to delete
it. A z/OS server lists
+ * a data set which other data sets are named below as a folder, so this
does happen.
+ */
+ static void checkTargetIsAFile(FileObject targetFile, String targetUri)
throws HopException {
+ try {
+ if (targetFile.exists() && !targetFile.isFile()) {
+ String message = BaseMessages.getString(PKG,
"SftpPut.Error.TargetIsNotAFile", targetUri);
+ if (targetFile.getName() instanceof IRootPrefixFileName prefixed
+ && !prefixed.getRootPrefix().isEmpty()) {
+ message += " " + BaseMessages.getString(PKG,
"SftpPut.Error.TargetIsNotAFile.DataSet");
+ }
+ throw new HopException(message);
+ }
+ } catch (FileSystemException e) {
+ throw new HopException(
+ BaseMessages.getString(PKG, "SftpPut.Error.UnableToUpload",
targetUri), e);
+ }
+ }
+
+ /**
+ * What goes to the error stream: the message, and the deepest cause, which
is usually the answer
+ * of the server.
+ */
+ static String errorDescription(Throwable e) {
+ Throwable rootCause = ExceptionUtils.getRootCause(e);
+ if (rootCause == null || rootCause == e) {
+ return e.toString();
+ }
+ return e.toString()
+ + Const.CR
+ + BaseMessages.getString(
+ PKG, "SftpPut.Error.RootCause",
ExceptionUtils.getRootCauseMessage(e));
+ }
+
private void createRemoteFolder(String remoteFolder) throws HopException {
if (Utils.isEmpty(remoteFolder)) {
// No folder given: the file lands in the folder the connection starts
in.
diff --git
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/client/SftpClient.java
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/client/SftpClient.java
index 770ba97e99..230eea0d25 100644
---
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/client/SftpClient.java
+++
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/client/SftpClient.java
@@ -17,6 +17,8 @@
package org.apache.hop.vfs.sftp.client;
+import static
org.apache.hop.vfs.sftp.provider.SftpFileNameParser.keepDataSetPrefix;
+
import com.google.common.annotations.VisibleForTesting;
import com.jcraft.jsch.Channel;
import com.jcraft.jsch.ChannelSftp;
@@ -178,7 +180,7 @@ public class SftpClient {
public void chdir(String dirToChangeTo) throws HopWorkflowException {
try {
- c.cd(dirToChangeTo.replace("\\\\", "/").replace("\\", "/"));
+ c.cd(keepDataSetPrefix(dirToChangeTo.replace("\\\\", "/").replace("\\",
"/")));
} catch (SftpException e) {
throw new HopWorkflowException(e);
}
@@ -262,7 +264,7 @@ public class SftpClient {
public void delete(String file) throws HopWorkflowException {
try {
- c.rm(file);
+ c.rm(keepDataSetPrefix(file));
} catch (SftpException e) {
throw new HopWorkflowException(e);
}
@@ -283,6 +285,7 @@ public class SftpClient {
for (String f : folders) {
if (!f.isEmpty() && !folderExists(f)) {
+ // jsch sends a mkdir as is, so unlike the other requests it needs
no trailing slash
c.mkdir(f);
}
}
@@ -295,7 +298,7 @@ public class SftpClient {
public void renameFile(String sourcefilename, String destinationfilename)
throws HopWorkflowException {
try {
- c.rename(sourcefilename, destinationfilename);
+ c.rename(keepDataSetPrefix(sourcefilename),
keepDataSetPrefix(destinationfilename));
} catch (SftpException e) {
throw new HopWorkflowException(e);
}
@@ -303,7 +306,7 @@ public class SftpClient {
public FileType getFileType(String filename) throws HopWorkflowException {
try {
- SftpATTRS attrs = c.stat(filename);
+ SftpATTRS attrs = c.stat(keepDataSetPrefix(filename));
if (attrs == null) {
return FileType.IMAGINARY;
}
@@ -325,7 +328,7 @@ public class SftpClient {
public boolean folderExists(String folderName) {
boolean retval = false;
try {
- SftpATTRS attrs = c.stat(folderName);
+ SftpATTRS attrs = c.stat(keepDataSetPrefix(folderName));
if (attrs == null) {
return false;
}
diff --git
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileNameParser.java
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileNameParser.java
index ca677bc6a3..4ba11818a8 100644
---
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileNameParser.java
+++
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileNameParser.java
@@ -127,4 +127,29 @@ public class SftpFileNameParser extends URLFileNameParser {
}
return "";
}
+
+ /**
+ * Gets a data set name of a single level, such as {@code //HLQ.NAME}, to
the server intact.
+ *
+ * <p>jsch runs almost every path through its glob handling, which splits
the path at the last
+ * slash and so turns {@code //HLQ.NAME} into {@code /HLQ.NAME} - the USS
path. With a trailing
+ * slash the last slash comes after the data set name, and {@code
//HLQ.NAME/} reaches the server
+ * as is. Paths of more than one level, and every path not starting with
exactly two slashes, are
+ * left alone.
+ *
+ * <p>Verified on z/OS for a data set name prefix used as a folder. A
single-level sequential data
+ * set read or written as a file gets the trailing slash too, which hasn't
been verified yet.
+ *
+ * @param path a path as it goes to jsch
+ * @return the path, with a trailing slash if it's a data set name of a
single level
+ */
+ public static String keepDataSetPrefix(String path) {
+ return path != null
+ && path.length() > 2
+ && path.startsWith("//")
+ && path.charAt(2) != '/'
+ && path.indexOf('/', 2) < 0
+ ? path + "/"
+ : path;
+ }
}
diff --git
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileObject.java
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileObject.java
index b17101d953..a76e0581eb 100644
---
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileObject.java
+++
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileObject.java
@@ -117,23 +117,35 @@ public class SftpFileObject extends
AbstractFileObject<SftpFileSystem> {
* USS path. Absolute is deliberate: jsch passes a path starting with a
slash to the server
* untouched, while a relative one is resolved against a working directory
the server would have
* normalised for us.
+ *
+ * <p>A data set name of a single level gets a trailing slash, or jsch would
take the second slash
+ * off again - see {@link SftpFileNameParser#keepDataSetPrefix}.
*/
private static String remotePath(final AbstractFileName fileName, final
SftpFileSystem fileSystem)
throws FileSystemException {
final String rootPrefix =
fileName instanceof IRootPrefixFileName prefixed ?
prefixed.getRootPrefix() : "";
if (!rootPrefix.isEmpty()) {
- return rootPrefix + UriParser.decode(fileName.getPath());
+ return SftpFileNameParser.keepDataSetPrefix(
+ rootPrefix + UriParser.decode(fileName.getPath()));
}
return
UriParser.decode(fileSystem.getRootName().getRelativeName(fileName));
}
- /** Creates this file as a folder. */
+ /**
+ * Creates this file as a folder. jsch sends a mkdir as is, unlike the
requests it globs, so a
+ * data set name goes without the trailing slash of {@link #relPath} - see
{@link
+ * SftpFileNameParser#keepDataSetPrefix}.
+ */
@Override
protected void doCreateFolder() throws Exception {
+ final String folderPath =
+ relPath.length() > 3 && relPath.startsWith("//") &&
relPath.endsWith("/")
+ ? relPath.substring(0, relPath.length() - 1)
+ : relPath;
final ChannelSftp channel = getAbstractFileSystem().getChannel();
try {
- channel.mkdir(relPath);
+ channel.mkdir(folderPath);
} finally {
putChannel(channel);
}
diff --git
a/plugins/tech/sftp/src/main/resources/org/apache/hop/pipeline/transforms/sftpput/messages/messages_en_US.properties
b/plugins/tech/sftp/src/main/resources/org/apache/hop/pipeline/transforms/sftpput/messages/messages_en_US.properties
index 126387ebbd..07b011a3ee 100644
---
a/plugins/tech/sftp/src/main/resources/org/apache/hop/pipeline/transforms/sftpput/messages/messages_en_US.properties
+++
b/plugins/tech/sftp/src/main/resources/org/apache/hop/pipeline/transforms/sftpput/messages/messages_en_US.properties
@@ -64,6 +64,9 @@ SftpPut.Error.RemoteFilenameEmpty=The remote file name is
empty
SftpPut.Error.CanNotFindRemoteFolder=Unable to find remote folder [{0}]
SftpPut.Error.MoveToDestinationFolderIsEmpty=The folder to move the source
file to is empty
SftpPut.Error.UnableToUpload=Unable to upload to [{0}]
+SftpPut.Error.TargetIsNotAFile=Unable to upload to [{0}]: it already exists on
the server and isn''t a file, so it can''t be overwritten.
+SftpPut.Error.TargetIsNotAFile.DataSet=On z/OS this is an existing data set
the server lists as a folder, or a name other data sets are stored under.
+SftpPut.Error.RootCause=Root cause: {0}
SftpPut.Error.UnableToMove=Unable to move [{0}] to [{1}]
SftpPut.Error.ConnectionNotRegistered=The SFTP connection [{0}] is not
available as a VFS scheme in this run. Please make sure it''s part of the
metadata of the project this pipeline runs in.
diff --git
a/plugins/tech/sftp/src/test/java/org/apache/hop/pipeline/transforms/sftpput/SftpPutTest.java
b/plugins/tech/sftp/src/test/java/org/apache/hop/pipeline/transforms/sftpput/SftpPutTest.java
index e6890f5c38..4c58d4827a 100644
---
a/plugins/tech/sftp/src/test/java/org/apache/hop/pipeline/transforms/sftpput/SftpPutTest.java
+++
b/plugins/tech/sftp/src/test/java/org/apache/hop/pipeline/transforms/sftpput/SftpPutTest.java
@@ -22,9 +22,16 @@ import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.junit.jupiter.api.Assertions.assertTrue;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.doReturn;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.spy;
import static org.mockito.Mockito.when;
+import static org.mockito.Mockito.withSettings;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
import org.apache.hop.core.HopEnvironment;
import org.apache.hop.core.exception.HopException;
import org.apache.hop.core.logging.ILoggingObject;
@@ -38,10 +45,12 @@ import
org.apache.hop.pipeline.transforms.mock.TransformMockHelper;
import org.apache.hop.pipeline.transforms.sftpput.SftpPutMeta.AfterSftpPut;
import org.apache.hop.vfs.sftp.SftpConnectionFileProvider;
import org.apache.hop.vfs.sftp.metadata.SftpConnection;
+import org.apache.hop.vfs.sftp.provider.IRootPrefixFileName;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
/**
* What the transform makes of its configuration before it touches the server:
every one of these
@@ -182,6 +191,69 @@ class SftpPutTest {
assertFalse(transform.processRow(), "the transform should have stopped");
}
+ /** A folder where the file should go can't be overwritten, and the message
says so. */
+ @Test
+ void testATargetWhichIsAFolderIsReported(@TempDir Path tempDir) throws
Exception {
+ String uri = tempDir.toUri().toString();
+ try (FileObject folder = HopVfs.getFileObject(uri)) {
+ HopException e =
+ assertThrows(HopException.class, () ->
SftpPut.checkTargetIsAFile(folder, uri));
+ assertTrue(e.getMessage().contains("isn't a file"), e.getMessage());
+ assertFalse(e.getMessage().contains("z/OS"), "not a data set: " +
e.getMessage());
+ }
+ }
+
+ /** Only a data set path gets the z/OS explanation. */
+ @Test
+ void testADataSetWhichIsAFolderIsExplained() throws Exception {
+ FileName name = mock(FileName.class,
withSettings().extraInterfaces(IRootPrefixFileName.class));
+ when(((IRootPrefixFileName) name).getRootPrefix()).thenReturn("/");
+ FileObject dataSet = mock(FileObject.class);
+ when(dataSet.exists()).thenReturn(true);
+ when(dataSet.isFile()).thenReturn(false);
+ when(dataSet.getName()).thenReturn(name);
+
+ HopException e =
+ assertThrows(
+ HopException.class,
+ () -> SftpPut.checkTargetIsAFile(dataSet,
"prod:////HLQ.NAME/MEMBER"));
+ assertTrue(e.getMessage().contains("isn't a file"), e.getMessage());
+ assertTrue(e.getMessage().contains("z/OS"), e.getMessage());
+ }
+
+ /** A file, or nothing at all, is fine. */
+ @Test
+ void testATargetWhichIsAFileOrMissingIsAccepted(@TempDir Path tempDir)
throws Exception {
+ Path file = Files.writeString(tempDir.resolve("existing.txt"), "x");
+ try (FileObject existing = HopVfs.getFileObject(file.toUri().toString());
+ FileObject missing =
HopVfs.getFileObject(tempDir.resolve("new.txt").toUri().toString())) {
+ SftpPut.checkTargetIsAFile(existing, "existing");
+ SftpPut.checkTargetIsAFile(missing, "missing");
+ }
+ }
+
+ /** The error stream gets the deepest cause too: that's where the server's
answer is. */
+ @Test
+ void testTheErrorDescriptionHasTheRootCause() {
+ Exception e =
+ new HopException(
+ "Unable to upload",
+ new IOException("Could not create file", new IOException("4:
Failure")));
+
+ String description = SftpPut.errorDescription(e);
+
+ assertTrue(description.contains("Unable to upload"), description);
+ assertTrue(description.contains("Root cause: IOException: 4: Failure"),
description);
+ }
+
+ /** Without a cause there's nothing to add. */
+ @Test
+ void testTheErrorDescriptionWithoutACause() {
+ Exception e = new HopException("Unable to find remote folder");
+
+ assertEquals(e.toString(), SftpPut.errorDescription(e));
+ }
+
private SftpPutMeta meta() {
SftpPutMeta meta = new SftpPutMeta();
meta.setConnection(CONNECTION_NAME);
diff --git
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/provider/SftpDoubleSlashPathTest.java
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/provider/SftpDoubleSlashPathTest.java
index 59588fab99..d3ed0d9a3b 100644
---
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/provider/SftpDoubleSlashPathTest.java
+++
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/provider/SftpDoubleSlashPathTest.java
@@ -30,6 +30,7 @@ import com.jcraft.jsch.TestIdentityRepositoryFactory;
import java.io.File;
import java.io.IOException;
import java.io.InputStream;
+import java.net.InetAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.InvalidPathException;
@@ -44,6 +45,7 @@ import org.apache.commons.vfs2.FileObject;
import org.apache.commons.vfs2.FileSystemException;
import org.apache.commons.vfs2.FileSystemManager;
import org.apache.commons.vfs2.FileSystemOptions;
+import org.apache.commons.vfs2.FileType;
import org.apache.commons.vfs2.NameScope;
import org.apache.commons.vfs2.RandomAccessContent;
import org.apache.commons.vfs2.auth.StaticUserAuthenticator;
@@ -60,6 +62,7 @@ import org.apache.hop.core.variables.Variables;
import org.apache.hop.vfs.sftp.SftpConnectionFileNameParser;
import org.apache.hop.vfs.sftp.SftpConnectionFileProvider;
import org.apache.hop.vfs.sftp.SftpConnections;
+import org.apache.hop.vfs.sftp.client.SftpClient;
import org.apache.hop.vfs.sftp.metadata.SftpConnection;
import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
import org.apache.sshd.server.SshServer;
@@ -74,6 +77,7 @@ import org.junit.jupiter.api.DisplayName;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.CsvSource;
import org.junit.jupiter.params.provider.ValueSource;
/**
@@ -283,6 +287,27 @@ class SftpDoubleSlashPathTest {
"the server should have been asked for a //-path, but got: " +
paths);
}
+ /**
+ * A data set name of a single level, the remote folder of the SFTP Put
transform for instance.
+ * jsch would turn {@code //read-tests} into {@code /read-tests}; the
trailing slash keeps both.
+ */
+ @Test
+ void aSingleLevelDataSetKeepsTheSecondSlash() throws Exception {
+ final String uri = SftpConnections.buildUri(namedConnection(),
"//read-tests/", null);
+ REQUESTED_PATHS.clear();
+ try (FileObject folder = manager.resolveFile(uri, authOptions())) {
+ assertEquals(FileType.FOLDER, folder.getType());
+ }
+ final List<String> paths = new ArrayList<>(REQUESTED_PATHS);
+
+ assertTrue(
+ paths.contains("//read-tests/"),
+ "the server should have been asked for the data set, but got: " +
paths);
+ assertFalse(
+ paths.contains("/read-tests"),
+ "the server was asked for the single-slash path: " + paths);
+ }
+
/** A random-access read opens the same file a plain read does: the data
set. */
@Test
void aRandomAccessReadAsksForTheDataSet() throws Exception {
@@ -360,6 +385,121 @@ class SftpDoubleSlashPathTest {
}
}
+ /**
+ * The workflow actions talk to jsch through {@link SftpClient} rather than
through VFS, with the
+ * folder as the user typed it, so the second slash of a single-level data
set name needs the same
+ * protection there.
+ */
+ @Nested
+ @DisplayName("A data set through the SftpClient of the workflow actions")
+ class SftpClientDataSets {
+
+ @ParameterizedTest(name = "{0} -> {1}")
+ @CsvSource({
+ "//A.B.C, //A.B.C/",
+ "//A.B.C/, //A.B.C/",
+ "//A.B.C/MEMBER, //A.B.C/MEMBER",
+ "/A.B.C, /A.B.C",
+ "///A.B.C, ///A.B.C",
+ "//, //",
+ "A.B.C, A.B.C"
+ })
+ void onlyASingleLevelDataSetGetsATrailingSlash(String path, String
expected) {
+ assertEquals(expected, SftpFileNameParser.keepDataSetPrefix(path));
+ }
+
+ @Test
+ void folderExistsAsksForTheDataSet() throws Exception {
+ final List<String> paths =
+ withClient(client ->
assertTrue(client.folderExists("//read-tests")));
+
+ assertAskedForTheDataSet(paths, "read-tests");
+ }
+
+ @Test
+ void getFileTypeAsksForTheDataSet() throws Exception {
+ final List<String> paths =
+ withClient(client -> assertEquals(FileType.FOLDER,
client.getFileType("//read-tests")));
+
+ assertAskedForTheDataSet(paths, "read-tests");
+ }
+
+ /**
+ * A cd asks for the real path first and then looks at whatever the server
answered, here the
+ * single-slash path of the test server: only the first request is ours.
+ */
+ @Test
+ void chdirAsksForTheDataSet() throws Exception {
+ final List<String> paths = withClient(client ->
client.chdir("//read-tests"));
+
+ assertFalse(paths.isEmpty(), "the server wasn't asked anything");
+ assertEquals("//read-tests/", paths.get(0), "paths asked for: " + paths);
+ }
+
+ /**
+ * The existence check needs the trailing slash, the mkdir - which jsch
sends as is - doesn't.
+ */
+ @Test
+ void createFolderAsksForTheDataSet() throws Exception {
+ final File created = new File(getTestDirectoryFile(), "new-data-set");
+ try {
+ final List<String> paths = withClient(client ->
client.createFolder("//new-data-set"));
+
+ assertTrue(created.isDirectory(), "the folder should have been
created");
+ assertAskedForTheDataSet(paths, "new-data-set");
+ assertTrue(paths.contains("//new-data-set"), "no mkdir of the data set
in: " + paths);
+ } finally {
+ Files.deleteIfExists(created.toPath());
+ }
+ }
+
+ /** The same goes for creating a folder through VFS. */
+ @Test
+ void aVfsFolderIsCreatedWithoutTheTrailingSlash() throws Exception {
+ final File created = new File(getTestDirectoryFile(),
"new-vfs-data-set");
+ try (FileObject folder =
+ manager.resolveFile(
+ SftpConnections.buildUri(namedConnection(),
"//new-vfs-data-set", null),
+ authOptions())) {
+ REQUESTED_PATHS.clear();
+ folder.createFolder();
+ final List<String> paths = new ArrayList<>(REQUESTED_PATHS);
+
+ assertTrue(created.isDirectory(), "the folder should have been
created");
+ assertTrue(paths.contains("//new-vfs-data-set"), "no mkdir of the data
set in: " + paths);
+ } finally {
+ Files.deleteIfExists(created.toPath());
+ }
+ }
+
+ private void assertAskedForTheDataSet(List<String> paths, String name) {
+ assertTrue(
+ paths.contains("//" + name + "/"),
+ "the server should have been asked for the data set, but got: " +
paths);
+ assertFalse(
+ paths.contains("/" + name), "the server was asked for the
single-slash path: " + paths);
+ }
+
+ /** Log in to the test server, run the action, and return the raw paths
the server saw. */
+ private List<String> withClient(ClientAction action) throws Exception {
+ final SftpClient client =
+ new SftpClient(InetAddress.getByName("localhost"), serverPort, USER);
+ try {
+ client.login(PASSWORD);
+ REQUESTED_PATHS.clear();
+ action.run(client);
+ return new ArrayList<>(REQUESTED_PATHS);
+ } finally {
+ client.disconnect();
+ }
+ }
+ }
+
+ @FunctionalInterface
+ private interface ClientAction {
+ void run(SftpClient client) throws Exception;
+ }
+
/**
* Windows UNC names ({@code \\server\share\...}) are the one place where
Commons VFS keeps a
* leading double slash, and the way it does that is the way out for the
mainframe as well: the