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 807debeea3 allow // as valid path for SFTP targets, fixes #7930 (#8886)
807debeea3 is described below

commit 807debeea340451da6f9cb6fa376848841e606f2
Author: Hans Van Akelyen <[email protected]>
AuthorDate: Tue Oct 13 16:58:19 2026 +0200

    allow // as valid path for SFTP targets, fixes #7930 (#8886)
---
 .../apache/hop/core/vfs/HopVfsGetFilenameTest.java | 123 ++++++
 .../hop/core/vfs/LocalFileNameParserShapeTest.java | 277 +++++++++++++
 .../org/apache/hop/junit/vfs/VfsPathShapes.java    | 240 +++++++++++
 .../org/apache/hop/vfs/ftp/FtpPathShapeTest.java   | 105 +++++
 .../hop/vfs/sftp/SftpConnectionFileName.java       |  31 +-
 .../hop/vfs/sftp/SftpConnectionFileNameParser.java |  29 +-
 .../org/apache/hop/vfs/sftp/SftpConnections.java   |  14 +-
 .../hop/vfs/sftp/provider/IRootPrefixFileName.java |  40 ++
 .../apache/hop/vfs/sftp/provider/SftpFileName.java |  79 ++++
 .../hop/vfs/sftp/provider/SftpFileNameParser.java  |  88 ++++
 .../hop/vfs/sftp/provider/SftpFileObject.java      |  27 +-
 .../hop/vfs/sftp/provider/SftpFileSystem.java      |   9 +-
 .../apache/hop/vfs/sftp/SftpConnectionsTest.java   |  21 +
 .../org/apache/hop/vfs/sftp/SftpPathShapeTest.java | 189 +++++++++
 .../vfs/sftp/metadata/SftpConnectionJsonTest.java  | 100 +++++
 .../vfs/sftp/provider/SftpDoubleSlashPathTest.java | 441 +++++++++++++++++++++
 16 files changed, 1792 insertions(+), 21 deletions(-)

diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/HopVfsGetFilenameTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/HopVfsGetFilenameTest.java
new file mode 100644
index 0000000000..62a5ac3a2e
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/core/vfs/HopVfsGetFilenameTest.java
@@ -0,0 +1,123 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileObject;
+import org.apache.commons.vfs2.FileSystemManager;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
+import org.apache.commons.vfs2.provider.local.GenericFileNameParser;
+import org.apache.commons.vfs2.provider.local.LocalFileNameParser;
+import org.apache.commons.vfs2.provider.local.WindowsFileNameParser;
+import org.apache.hop.core.Const;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+/**
+ * The four branches of {@link HopVfs#getFilename(FileObject)}, which turns a 
file object back into
+ * something to hand to the operating system.
+ *
+ * <p>It is worth pinning all four before touching any of them. Around 140 
call sites across 65
+ * files go through this method and its {@link HopVfs#normalize(String)} 
wrapper, no test called it
+ * directly until this one, and its single biggest caller - the VFS file 
dialog - is excluded from
+ * the coverage report by the {@code **}{@code /*Dialog*} jacoco exclusion. So 
nothing in the build
+ * would notice if a branch changed.
+ *
+ * <p>Note that the contract is not uniform: two branches return a 
<em>URI</em> and two return an
+ * <em>operating system path</em>. Anything handing the result to {@code new 
File(...)} therefore
+ * behaves differently for a UNC share than for a drive letter.
+ */
+class HopVfsGetFilenameTest {
+
+  /** A file object is only ever asked for its name, so a name is all a 
stand-in needs. */
+  private static FileObject fileObjectNamed(FileName name) {
+    FileObject fileObject = mock(FileObject.class);
+    when(fileObject.getName()).thenReturn(name);
+    return fileObject;
+  }
+
+  private static FileName parse(LocalFileNameParser parser, String uri) throws 
Exception {
+    FileSystemManager fsm = mock(FileSystemManager.class);
+    when(fsm.getSchemes()).thenReturn(new String[] {"file"});
+    VfsComponentContext context = mock(VfsComponentContext.class);
+    when(context.getFileSystemManager()).thenReturn(fsm);
+    return parser.parseUri(context, null, uri);
+  }
+
+  /** The separators the caller's platform expects, for the branches returning 
an OS path. */
+  private static String osPath(String slashPath) {
+    return "/".equals(Const.FILE_SEPARATOR)
+        ? slashPath
+        : Const.replace(slashPath, "/", Const.FILE_SEPARATOR);
+  }
+
+  @Test
+  @DisplayName("a non-file scheme comes back as its URI, untouched")
+  void nonFileSchemeReturnsTheUri() {
+    FileName name = mock(FileName.class);
+    when(name.getRootURI()).thenReturn("sftp://user@host/";);
+    when(name.getURI()).thenReturn("sftp://user@host/inbox/customers.csv";);
+
+    assertEquals("sftp://user@host/inbox/customers.csv";, 
HopVfs.getFilename(fileObjectNamed(name)));
+  }
+
+  @Test
+  @DisplayName("a UNC share comes back as its URI, all four slashes intact")
+  void uncShareReturnsTheUri() throws Exception {
+    FileName name = parse(new WindowsFileNameParser(), 
"file:////server/share/dir/file.txt");
+
+    // Guard the precondition this branch keys off, so a change of root shows 
up here too.
+    assertEquals("file:////server/share/", name.getRootURI());
+    assertEquals("file:////server/share/dir/file.txt", 
HopVfs.getFilename(fileObjectNamed(name)));
+  }
+
+  @Test
+  @DisplayName("a Windows drive comes back as an OS path, drive letter 
included")
+  void windowsDriveReturnsAnOsPath() throws Exception {
+    FileName name = parse(new WindowsFileNameParser(), 
"file:///C:/temp/file.txt");
+
+    assertEquals("file:///C:/", name.getRootURI());
+    assertEquals(osPath("C:/temp/file.txt"), 
HopVfs.getFilename(fileObjectNamed(name)));
+  }
+
+  @Test
+  @DisplayName("everywhere else comes back as an OS path with no root prefix")
+  void posixReturnsAnOsPath() throws Exception {
+    FileName name = parse(new GenericFileNameParser(), 
"file:///home/me/file.txt");
+
+    assertEquals("file:///", name.getRootURI());
+    assertEquals(osPath("/home/me/file.txt"), 
HopVfs.getFilename(fileObjectNamed(name)));
+  }
+
+  /**
+   * A UNC-looking name parsed by the generic parser is an ordinary path, so 
it takes the OS-path
+   * branch rather than the URI one. Same input, different answer per platform 
- which is the thing
+   * to keep in mind when changing any of this.
+   */
+  @Test
+  @DisplayName("a UNC-looking name under the generic parser is just a path")
+  void uncUnderTheGenericParserIsJustAPath() throws Exception {
+    FileName name = parse(new GenericFileNameParser(), 
"file:////server/share/dir/file.txt");
+
+    assertEquals("file:///", name.getRootURI());
+    assertEquals(osPath("/server/share/dir/file.txt"), 
HopVfs.getFilename(fileObjectNamed(name)));
+  }
+}
diff --git 
a/core/src/test/java/org/apache/hop/core/vfs/LocalFileNameParserShapeTest.java 
b/core/src/test/java/org/apache/hop/core/vfs/LocalFileNameParserShapeTest.java
new file mode 100644
index 0000000000..52cdb53659
--- /dev/null
+++ 
b/core/src/test/java/org/apache/hop/core/vfs/LocalFileNameParserShapeTest.java
@@ -0,0 +1,277 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.core.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileSystemException;
+import org.apache.commons.vfs2.FileSystemManager;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
+import org.apache.commons.vfs2.provider.local.GenericFileNameParser;
+import org.apache.commons.vfs2.provider.local.LocalFileNameParser;
+import org.apache.commons.vfs2.provider.local.WindowsFileNameParser;
+import org.apache.hop.junit.vfs.VfsPathShapes;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the local file name parsers make of the awkward path shapes in {@link 
VfsPathShapes}.
+ *
+ * <p>Both parsers are exercised by name rather than through {@link HopVfs}, 
so this pins Windows
+ * behaviour on every platform. That matters more here than anywhere else: the 
CI workflows all run
+ * on {@code ubuntu-latest}, so nothing else in the build would notice a 
Windows path regression,
+ * and UNC is the one place in Commons VFS where a leading double slash is 
meant to survive.
+ */
+class LocalFileNameParserShapeTest {
+
+  private static VfsPathShapes.NameParser parserFor(LocalFileNameParser 
parser) {
+    FileSystemManager fsm = mock(FileSystemManager.class);
+    when(fsm.getSchemes()).thenReturn(new String[] {"file"});
+    VfsComponentContext context = mock(VfsComponentContext.class);
+    when(context.getFileSystemManager()).thenReturn(fsm);
+    return uri -> parser.parseUri(context, null, uri);
+  }
+
+  @Nested
+  @DisplayName("Windows, drive letter")
+  class WindowsDrive {
+
+    private static final String BASE = "file:///C:";
+    private final VfsPathShapes.NameParser parser = parserFor(new 
WindowsFileNameParser());
+
+    @Test
+    void paths() {
+      VfsPathShapes.assertPaths(
+          BASE,
+          parser,
+          """
+          plain                   /dir/file.txt
+          leading-double-slash    /dir/file.txt
+          leading-triple-slash    /dir/file.txt
+          interior-double-slash   /dir/file.txt
+          trailing-slash          /dir
+          root-only               /
+          no-path                 error          # "file:///C:" has no 
separator after the drive
+          dot-segment             /dir/file.txt
+          parent-segment          /dir/file.txt
+          parent-above-root       error
+          encoded-slash           /dir/file.txt  # %2F decodes before it is 
normalised away
+          space                   /dir/my file.txt
+          encoded-space           /dir/my file.txt
+          hash                    /dir/file#1.txt
+          question-mark           /dir/file?1.txt
+          plus                    /dir/file+1.txt
+          backslash               /dir/file.txt  # a backslash becomes a 
separator
+          leading-dots-in-name    /dir/..hidden
+          unicode                 /dir/naïve.txt
+          """);
+    }
+
+    @Test
+    void theDriveStaysInTheRoot() throws Exception {
+      assertEquals("file:///C:/", parser.parse(BASE + 
"/dir/file.txt").getRootURI());
+    }
+
+    @Test
+    void uriRoundTrips() {
+      VfsPathShapes.assertUriRoundTrips(BASE, parser);
+    }
+  }
+
+  /**
+   * The one shape which survives anywhere in Commons VFS: {@code 
//server/share} is hoisted into
+   * the root by {@link WindowsFileNameParser}, so it is never handed to the 
normaliser as a path.
+   * Every URI below keeps all four of its leading slashes, and that is what a 
UNC fix must not
+   * break.
+   */
+  @Nested
+  @DisplayName("Windows, UNC share")
+  class WindowsUnc {
+
+    private static final String BASE = "file:////server/share";
+    private final VfsPathShapes.NameParser parser = parserFor(new 
WindowsFileNameParser());
+
+    private FileName parse(String uri) throws Exception {
+      return parser.parse(uri);
+    }
+
+    @Test
+    void paths() {
+      VfsPathShapes.assertPaths(
+          BASE,
+          parser,
+          """
+          plain                   /dir/file.txt
+          leading-double-slash    /dir/file.txt
+          leading-triple-slash    /dir/file.txt
+          interior-double-slash   /dir/file.txt
+          trailing-slash          /dir
+          root-only               /
+          no-path                 /
+          dot-segment             /dir/file.txt
+          parent-segment          /dir/file.txt
+          parent-above-root       error
+          encoded-slash           /dir/file.txt
+          space                   /dir/my file.txt
+          encoded-space           /dir/my file.txt
+          hash                    /dir/file#1.txt
+          question-mark           /dir/file?1.txt
+          plus                    /dir/file+1.txt
+          backslash               /dir/file.txt
+          leading-dots-in-name    /dir/..hidden
+          unicode                 /dir/naïve.txt
+          """);
+    }
+
+    /**
+     * The root swallows exactly two segments, whatever they were meant to be: 
in {@code
+     * file:////servername/path/on/server} the share is {@code path}, not 
{@code servername}, and
+     * the file is {@code /on/server} beneath it.
+     */
+    @Test
+    void theRootIsHostAndShare() throws Exception {
+      final FileName name = parse("file:////servername/path/on/server");
+
+      assertEquals("file:////servername/path/", name.getRootURI());
+      assertEquals("/on/server", name.getPath());
+      assertEquals("file:////servername/path/on/server", name.getURI());
+    }
+
+    /**
+     * Two, three and four slashes all mean the same UNC share, because the 
parser skips up to four
+     * and only then looks for a drive letter. So {@code 
file:///servername/path} - which on any
+     * other platform is the plain local path {@code /servername/path} - is 
the share {@code
+     * \\servername\path} here. Nothing in CI would notice: every workflow 
runs on Linux.
+     */
+    @Test
+    void twoThreeAndFourSlashesAllMeanTheSameShare() throws Exception {
+      for (String uri :
+          new String[] {
+            "file://servername/path/on/server",
+            "file:///servername/path/on/server",
+            "file:////servername/path/on/server"
+          }) {
+        assertEquals("file:////servername/path/", parse(uri).getRootURI(), 
uri);
+        assertEquals("/on/server", parse(uri).getPath(), uri);
+      }
+    }
+
+    /** A drive letter still wins over the UNC reading. */
+    @Test
+    void aDriveLetterIsNotAShare() throws Exception {
+      assertEquals("file:///C:/", parse("file:///C:/temp/x.txt").getRootURI());
+    }
+
+    /** A host on its own is not a share, and is refused rather than guessed 
at. */
+    @Test
+    void aHostWithoutAShareIsRejected() {
+      final FileSystemException e =
+          assertThrows(FileSystemException.class, () -> 
parse("file:////servername"));
+      assertTrue(
+          e.getMessage().contains("Share name missing"), "unexpected message: 
" + e.getMessage());
+    }
+
+    /** The share is part of the root, which is why it survives normalisation. 
*/
+    @Test
+    void theShareStaysInTheRoot() throws Exception {
+      assertEquals("file:////server/share/", parser.parse(BASE + 
"/dir/file.txt").getRootURI());
+      assertEquals(
+          "file:////server/share/dir/file.txt", parser.parse(BASE + 
"/dir/file.txt").getURI());
+    }
+
+    @Test
+    void uriRoundTrips() {
+      VfsPathShapes.assertUriRoundTrips(BASE, parser);
+    }
+  }
+
+  /**
+   * The parser every non-Windows platform gets. It has no root prefix to 
hoist anything into, so a
+   * leading double slash is flattened into the path and axed - the same 
collapse which makes a
+   * mainframe SFTP data set unaddressable.
+   */
+  @Nested
+  @DisplayName("Generic (non-Windows)")
+  class Generic {
+
+    private static final String BASE = "file://";
+    private final VfsPathShapes.NameParser parser = parserFor(new 
GenericFileNameParser());
+
+    private FileName parse(String uri) throws Exception {
+      return parser.parse(uri);
+    }
+
+    @Test
+    void paths() {
+      VfsPathShapes.assertPaths(
+          BASE,
+          parser,
+          """
+          plain                   /dir/file.txt
+          leading-double-slash    /dir/file.txt  # no hoist, so the share is 
flattened away
+          leading-triple-slash    /dir/file.txt
+          interior-double-slash   /dir/file.txt
+          trailing-slash          /dir
+          root-only               /
+          no-path                 /
+          dot-segment             /dir/file.txt
+          parent-segment          /dir/file.txt
+          parent-above-root       error
+          encoded-slash           /dir/file.txt
+          space                   /dir/my file.txt
+          encoded-space           /dir/my file.txt
+          hash                    /dir/file#1.txt
+          question-mark           /dir/file?1.txt
+          plus                    /dir/file+1.txt
+          backslash               /dir/file.txt
+          leading-dots-in-name    /dir/..hidden
+          unicode                 /dir/naïve.txt
+          """);
+    }
+
+    /**
+     * The same URI the Windows parser reads as a share is an ordinary local 
path here - so {@code
+     * file:////servername/path/on/server} names two different files depending 
on the platform.
+     */
+    @Test
+    void aShareIsJustAPathHere() throws Exception {
+      final FileName name = parse("file:////servername/path/on/server");
+
+      assertEquals("file:///", name.getRootURI());
+      assertEquals("/servername/path/on/server", name.getPath());
+      assertEquals("file:///servername/path/on/server", name.getURI());
+    }
+
+    /** Everything lands under a bare root, UNC-looking names included. */
+    @Test
+    void thereIsNoRootPrefixToHoistInto() throws Exception {
+      assertEquals("file:///", parser.parse(BASE + 
"//server/share/x").getRootURI());
+      assertEquals("/server/share/x", parser.parse(BASE + 
"//server/share/x").getPath());
+    }
+
+    @Test
+    void uriRoundTrips() {
+      VfsPathShapes.assertUriRoundTrips(BASE, parser);
+    }
+  }
+}
diff --git a/core/src/test/java/org/apache/hop/junit/vfs/VfsPathShapes.java 
b/core/src/test/java/org/apache/hop/junit/vfs/VfsPathShapes.java
new file mode 100644
index 0000000000..e2b2a5cd1e
--- /dev/null
+++ b/core/src/test/java/org/apache/hop/junit/vfs/VfsPathShapes.java
@@ -0,0 +1,240 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.junit.vfs;
+
+import static org.junit.jupiter.api.Assertions.assertAll;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.fail;
+
+import java.util.ArrayList;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.function.Function;
+import org.apache.commons.vfs2.FileName;
+import org.junit.jupiter.api.function.Executable;
+
+/**
+ * One shared set of awkward path shapes to run through every VFS file name 
parser, so what a parser
+ * makes of them is written down instead of assumed.
+ *
+ * <p>A file name parser is a handful of lines of its own on top of a Commons 
VFS base class, so
+ * line coverage says nothing useful about one: {@code SftpFileNameParser} 
sits at 100% and still
+ * cannot address a mainframe data set. What matters is which path shapes 
survive it, which is what
+ * this pins down. Expectations are written as a table, one shape per line, 
name and expected value
+ * separated by two or more spaces:
+ *
+ * <pre>
+ *   VfsPathShapes.assertPaths("sftp://user@host:22";, manager::resolveURI, """
+ *       plain                   /dir/file.txt
+ *       leading-double-slash    /dir/file.txt     # collapsed, see issue
+ *       ...
+ *       """);
+ * </pre>
+ *
+ * <p>Every shape in {@link #SHAPES} has to appear in the table and vice 
versa, so a shape added
+ * here surfaces as a failure in each parser's test rather than quietly going 
unchecked.
+ *
+ * @see #assertUriRoundTrips(String, NameParser) the invariant a fix has to 
keep
+ */
+public final class VfsPathShapes {
+
+  /** Expected value for a shape the parser rejects. */
+  public static final String ERROR = "error";
+
+  /** A path shape: a name to refer to it by, and the path to append to a base 
URI. */
+  public record Shape(String name, String suffix) {}
+
+  /**
+   * The shapes. Appended to a base URI such as {@code sftp://user@host:22} or 
{@code file://}, so
+   * each one starts at the root of the file system.
+   */
+  public static final List<Shape> SHAPES =
+      List.of(
+          new Shape("plain", "/dir/file.txt"),
+          // A mainframe data set and a Windows UNC share both live or die by 
these two.
+          new Shape("leading-double-slash", "//dir/file.txt"),
+          new Shape("leading-triple-slash", "///dir/file.txt"),
+          // What a base folder ending in "/" plus a child starting with "/" 
concatenates to.
+          new Shape("interior-double-slash", "/dir//file.txt"),
+          new Shape("trailing-slash", "/dir/"),
+          new Shape("root-only", "/"),
+          new Shape("no-path", ""),
+          new Shape("dot-segment", "/dir/./file.txt"),
+          new Shape("parent-segment", "/dir/sub/../file.txt"),
+          new Shape("parent-above-root", "/../file.txt"),
+          new Shape("encoded-slash", "/dir/%2Ffile.txt"),
+          new Shape("space", "/dir/my file.txt"),
+          new Shape("encoded-space", "/dir/my%20file.txt"),
+          new Shape("hash", "/dir/file#1.txt"),
+          new Shape("question-mark", "/dir/file?1.txt"),
+          new Shape("plus", "/dir/file+1.txt"),
+          new Shape("backslash", "/dir\\file.txt"),
+          new Shape("leading-dots-in-name", "/dir/..hidden"),
+          new Shape("unicode", "/dir/naïve.txt"));
+
+  /** Parses a URI into a file name, the way the provider under test would. */
+  @FunctionalInterface
+  public interface NameParser {
+    FileName parse(String uri) throws Exception;
+  }
+
+  /**
+   * Run every shape through the parser.
+   *
+   * @return shape name to the resulting path, or {@link #ERROR} if the parser 
rejected it
+   */
+  public static Map<String, String> paths(String baseUri, NameParser parser) {
+    return collect(baseUri, parser, FileName::getPath);
+  }
+
+  /**
+   * Run every shape through the parser.
+   *
+   * @return shape name to the resulting URI, or {@link #ERROR} if the parser 
rejected it
+   */
+  public static Map<String, String> uris(String baseUri, NameParser parser) {
+    return collect(baseUri, parser, FileName::getURI);
+  }
+
+  private static Map<String, String> collect(
+      String baseUri, NameParser parser, Function<FileName, String> render) {
+    Map<String, String> actual = new LinkedHashMap<>();
+    for (Shape shape : SHAPES) {
+      try {
+        actual.put(shape.name(), render.apply(parser.parse(baseUri + 
shape.suffix())));
+      } catch (Exception e) {
+        actual.put(shape.name(), ERROR);
+      }
+    }
+    return actual;
+  }
+
+  /** Assert the path each shape parses to, reporting every shape which moved 
at once. */
+  public static void assertPaths(String baseUri, NameParser parser, String 
expectedTable) {
+    assertEvery(paths(baseUri, parser), expectedTable, "path");
+  }
+
+  /** Assert the URI each shape parses to, reporting every shape which moved 
at once. */
+  public static void assertUris(String baseUri, NameParser parser, String 
expectedTable) {
+    assertEvery(uris(baseUri, parser), expectedTable, "URI");
+  }
+
+  /**
+   * One shape per line: the shape name, two or more spaces, the expected 
value. Blank lines are
+   * ignored, and so is a trailing note introduced by a {@code #} which is 
itself preceded by two or
+   * more spaces - the same separator the columns use, so that a {@code #} 
inside an expected value
+   * stays part of the value.
+   */
+  private static Map<String, String> parseTable(String table) {
+    Map<String, String> expected = new LinkedHashMap<>();
+    for (String rawLine : table.split("\n")) {
+      String line = rawLine.replaceAll("\\s{2,}#.*$", "").strip();
+      if (line.isEmpty() || line.startsWith("#")) {
+        continue;
+      }
+      String[] cells = line.split("\\s{2,}", 2);
+      if (cells.length != 2) {
+        fail(
+            "table line needs a name and a value separated by two or more 
spaces: \""
+                + line
+                + "\"");
+      }
+      expected.put(cells[0].strip(), cells[1].strip());
+    }
+    return expected;
+  }
+
+  private static void assertEvery(Map<String, String> actual, String table, 
String what) {
+    Map<String, String> expected = parseTable(table);
+
+    List<String> unknown = new ArrayList<>(expected.keySet());
+    unknown.removeAll(actual.keySet());
+    if (!unknown.isEmpty()) {
+      fail("table has rows for shapes which do not exist: " + unknown);
+    }
+
+    List<Executable> checks = new ArrayList<>();
+    for (Shape shape : SHAPES) {
+      String name = shape.name();
+      if (!expected.containsKey(name)) {
+        checks.add(
+            () ->
+                fail(
+                    "no expected "
+                        + what
+                        + " for shape \""
+                        + name
+                        + "\" ("
+                        + shape.suffix()
+                        + "), which parses to: "
+                        + actual.get(name)));
+        continue;
+      }
+      checks.add(
+          () ->
+              assertEquals(
+                  expected.get(name),
+                  actual.get(name),
+                  "shape \"" + name + "\" (" + shape.suffix() + ")"));
+    }
+    assertAll(what + " per shape", checks);
+  }
+
+  /**
+   * Assert that every shape which parses survives a trip through its own URI 
unchanged, for the
+   * name itself and for every one of its parents.
+   *
+   * <p>This is the invariant a fix has to keep, and the one most easily lost. 
A file name carries
+   * its root and its path separately, and only the root is handed on 
untouched: {@code
+   * createName(absPath, type)} is called with an already normalised path on 
every child listing and
+   * every {@link FileName#getParent()}. So anything a parser defends inside 
the <em>path</em> is
+   * re-normalised away the moment a folder is listed or a parent is walked 
to, while anything it
+   * puts in the <em>root</em> survives - which is why the Windows parser 
hoists a UNC share into
+   * the root rather than guarding it in the path.
+   */
+  public static void assertUriRoundTrips(String baseUri, NameParser parser) {
+    List<Executable> checks = new ArrayList<>();
+    for (Shape shape : SHAPES) {
+      FileName name;
+      try {
+        name = parser.parse(baseUri + shape.suffix());
+      } catch (Exception e) {
+        continue; // a shape the parser rejects has nothing to round trip
+      }
+      for (FileName each = name; each != null; each = each.getParent()) {
+        FileName toCheck = each;
+        String role = each == name ? "" : " (parent of " + name.getURI() + ")";
+        checks.add(
+            () -> {
+              FileName reparsed = parser.parse(toCheck.getURI());
+              assertEquals(
+                  toCheck.getPath(),
+                  reparsed.getPath(),
+                  "path of \"" + shape.name() + "\"" + role + " changed on 
reparsing its URI");
+              assertEquals(
+                  toCheck.getURI(),
+                  reparsed.getURI(),
+                  "URI of \"" + shape.name() + "\"" + role + " changed on 
reparsing itself");
+            });
+      }
+    }
+    assertAll("URI round trip per shape", checks);
+  }
+
+  private VfsPathShapes() {}
+}
diff --git 
a/plugins/tech/ftp/src/test/java/org/apache/hop/vfs/ftp/FtpPathShapeTest.java 
b/plugins/tech/ftp/src/test/java/org/apache/hop/vfs/ftp/FtpPathShapeTest.java
new file mode 100644
index 0000000000..d838de7ece
--- /dev/null
+++ 
b/plugins/tech/ftp/src/test/java/org/apache/hop/vfs/ftp/FtpPathShapeTest.java
@@ -0,0 +1,105 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.ftp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileSystemManager;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.vfs.VfsPathShapes;
+import org.apache.hop.vfs.ftp.metadata.FtpConnection;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the named FTP connection parser makes of the awkward path shapes in 
{@link VfsPathShapes}.
+ *
+ * <p>This is the FTP twin of {@code SftpPathShapeTest}, and the two are 
deliberately out of step
+ * for now: SFTP keeps a path rooted at a second slash, so that a z/OS MVS 
data set can be
+ * addressed, and FTP still collapses every leading slash into one. z/OS FTP 
names data sets the
+ * same {@code //A.B.C} way, so the same change belongs here - but the SFTP 
fix reaches the wire
+ * through Hop's own fork of the Commons VFS SFTP provider, and there is no 
such fork for FTP: the
+ * path sent to the server is built in {@code FtpFileObject}'s constructor, 
which Hop does not own.
+ * The rows below say what FTP does today, so that whatever is done about that 
is measurable.
+ */
+class FtpPathShapeTest {
+
+  private static final String BASE = "prod:/";
+
+  private final VfsPathShapes.NameParser parser = connectionParser();
+
+  private VfsPathShapes.NameParser connectionParser() {
+    FtpConnection connection = new FtpConnection();
+    connection.setName("prod");
+    connection.setServerName("mainframe.example.com");
+    connection.setServerPort("21");
+    connection.setUserName("hop");
+
+    FileSystemManager fsm = mock(FileSystemManager.class);
+    when(fsm.getSchemes()).thenReturn(new String[] {"prod"});
+    VfsComponentContext context = mock(VfsComponentContext.class);
+    when(context.getFileSystemManager()).thenReturn(fsm);
+
+    FtpConnectionFileNameParser nameParser =
+        new FtpConnectionFileNameParser(new Variables(), connection);
+    return uri -> nameParser.parseUri(context, null, uri);
+  }
+
+  @Test
+  void paths() {
+    VfsPathShapes.assertPaths(
+        BASE,
+        parser,
+        """
+        plain                   /dir/file.txt
+        leading-double-slash    /dir/file.txt    # an MVS data set, collapsed 
- unlike SFTP
+        leading-triple-slash    /dir/file.txt
+        interior-double-slash   /dir/file.txt
+        trailing-slash          /dir
+        root-only               /
+        no-path                 /
+        dot-segment             /dir/file.txt
+        parent-segment          /dir/file.txt
+        parent-above-root       error
+        encoded-slash           /dir/file.txt
+        space                   /dir/my file.txt
+        encoded-space           /dir/my file.txt
+        hash                    /dir/file#1.txt
+        question-mark           /dir/file?1.txt
+        plus                    /dir/file+1.txt
+        backslash               /dir/file.txt
+        leading-dots-in-name    /dir/..hidden
+        unicode                 /dir/naïve.txt
+        """);
+  }
+
+  /** Every leading slash count lands on the same root, which is what SFTP no 
longer does. */
+  @Test
+  void everySlashCountMeansTheSameRoot() throws Exception {
+    assertEquals("prod:///", parser.parse("prod://dir/file.txt").getRootURI());
+    assertEquals("prod:///", 
parser.parse("prod:///dir/file.txt").getRootURI());
+    assertEquals("prod:///", 
parser.parse("prod:////dir/file.txt").getRootURI());
+    assertEquals("prod:///dir/file.txt", 
parser.parse("prod:////dir/file.txt").getURI());
+  }
+
+  @Test
+  void uriRoundTrips() {
+    VfsPathShapes.assertUriRoundTrips(BASE, parser);
+  }
+}
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileName.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileName.java
index b79ecfe95a..7c4da2bdfc 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileName.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileName.java
@@ -20,6 +20,7 @@ import org.apache.commons.vfs2.FileName;
 import org.apache.commons.vfs2.FileType;
 import org.apache.commons.vfs2.provider.GenericFileName;
 import org.apache.hop.vfs.sftp.metadata.SftpConnection;
+import org.apache.hop.vfs.sftp.provider.IRootPrefixFileName;
 
 /**
  * The name of a file behind a named SFTP connection.
@@ -30,7 +31,9 @@ import org.apache.hop.vfs.sftp.metadata.SftpConnection;
  * point at today. That also means a URI taken from a file object can be 
handed straight back to
  * VFS.
  */
-public class SftpConnectionFileName extends GenericFileName {
+public class SftpConnectionFileName extends GenericFileName implements 
IRootPrefixFileName {
+
+  private final String rootPrefix;
 
   protected SftpConnectionFileName(
       String scheme,
@@ -39,7 +42,8 @@ public class SftpConnectionFileName extends GenericFileName {
       String userName,
       String password,
       String path,
-      FileType type) {
+      FileType type,
+      String rootPrefix) {
     super(
         scheme,
         hostName,
@@ -49,17 +53,34 @@ public class SftpConnectionFileName extends GenericFileName 
{
         password,
         path,
         type == null ? FileType.IMAGINARY : type);
+    this.rootPrefix = rootPrefix == null ? "" : rootPrefix;
+  }
+
+  @Override
+  public String getRootPrefix() {
+    return rootPrefix;
   }
 
-  /** {@code <connection name>://}, without server or credentials. */
+  /**
+   * {@code <connection name>://}, without server or credentials, plus the 
second slash of a path
+   * rooted at one - so {@code prod:////A.B.C} reads back as it was typed.
+   */
   @Override
   protected void appendRootUri(StringBuilder buffer, boolean addPassword) {
-    buffer.append(getScheme()).append("://");
+    buffer.append(getScheme()).append("://").append(rootPrefix);
   }
 
+  /** Children and parents keep the root they were reached through. */
   @Override
   public FileName createName(String absPath, FileType type) {
     return new SftpConnectionFileName(
-        getScheme(), getHostName(), getPort(), getUserName(), getPassword(), 
absPath, type);
+        getScheme(),
+        getHostName(),
+        getPort(),
+        getUserName(),
+        getPassword(),
+        absPath,
+        type,
+        rootPrefix);
   }
 }
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileNameParser.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileNameParser.java
index fb62cb654e..5b197c9a69 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileNameParser.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnectionFileNameParser.java
@@ -27,6 +27,7 @@ import org.apache.hop.core.Const;
 import org.apache.hop.core.util.Utils;
 import org.apache.hop.core.variables.IVariables;
 import org.apache.hop.vfs.sftp.metadata.SftpConnection;
+import org.apache.hop.vfs.sftp.provider.SftpFileNameParser;
 
 /**
  * Turns {@code <connection name>://path} (any number of slashes after the 
scheme) into a {@link
@@ -51,6 +52,7 @@ public class SftpConnectionFileNameParser extends 
AbstractFileNameParser {
     }
 
     StringBuilder buffer = new StringBuilder();
+    String rootPrefix = "";
     String scheme =
         UriParser.extractScheme(context.getFileSystemManager().getSchemes(), 
uri, buffer);
     if (scheme == null) {
@@ -68,15 +70,17 @@ public class SftpConnectionFileNameParser extends 
AbstractFileNameParser {
               + scheme
               + "\"");
     } else {
-      // Everything after the scheme is a path on the server: prod://tmp/x, 
prod:///tmp/x and
-      // prod:////tmp/x all point at /tmp/x.
+      // Everything after the scheme is a path on the server. The two slashes 
behind the scheme are
+      // the separator, as in any URI, and what is left is the path: 
prod://tmp/x and prod:///tmp/x
+      // both point at /tmp/x. A path which then still starts with two slashes 
keeps the second one
+      // - prod:////A.B.C is the MVS data set //A.B.C, the same way 
file:////server/share is a UNC
+      // share - and three or more collapse to one, as they do everywhere else.
       //
-      int slashes = 0;
-      while (slashes < buffer.length() && buffer.charAt(slashes) == '/') {
-        slashes++;
+      buffer.delete(0, Math.min(2, leadingSlashes(buffer)));
+      if (buffer.isEmpty() || buffer.charAt(0) != '/') {
+        buffer.insert(0, '/');
       }
-      buffer.delete(0, slashes);
-      buffer.insert(0, '/');
+      rootPrefix = SftpFileNameParser.resolveRootPrefix(buffer, uri, base);
     }
 
     UriParser.canonicalizePath(buffer, 0, buffer.length(), this);
@@ -94,6 +98,15 @@ public class SftpConnectionFileNameParser extends 
AbstractFileNameParser {
     String user = variables.resolve(connection.getUsername());
     String password = Utils.resolvePassword(variables, 
connection.getPassword());
 
-    return new SftpConnectionFileName(connectionName, host, port, user, 
password, path, fileType);
+    return new SftpConnectionFileName(
+        connectionName, host, port, user, password, path, fileType, 
rootPrefix);
+  }
+
+  private static int leadingSlashes(StringBuilder buffer) {
+    int slashes = 0;
+    while (slashes < buffer.length() && buffer.charAt(slashes) == '/') {
+      slashes++;
+    }
+    return slashes;
   }
 }
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnections.java 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnections.java
index 3ffca04ae4..e119596e47 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnections.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/SftpConnections.java
@@ -61,7 +61,7 @@ public final class SftpConnections {
 
   /**
    * Build the URI of a file behind a named connection. Both the folder and 
the file name are
-   * optional.
+   * optional. A path rooted at a second slash, such as the z/OS data set 
{@code //A.B.C}, keeps it.
    */
   public static String buildUri(SftpConnection connection, String folder, 
String filename) {
     StringBuilder uri = new StringBuilder(getBaseUri(connection));
@@ -72,10 +72,16 @@ public final class SftpConnections {
       }
       path += filename.replace('\\', '/').trim();
     }
-    while (path.startsWith("/")) {
-      path = path.substring(1);
+    // One leading slash or three and more mean the same, but exactly two is a 
path of its own,
+    // which the parser reads from prod:////A.B.C.
+    int slashes = 0;
+    while (slashes < path.length() && path.charAt(slashes) == '/') {
+      slashes++;
     }
-    return uri.append(path).toString();
+    if (slashes == 2) {
+      uri.append("//");
+    }
+    return uri.append(path.substring(slashes)).toString();
   }
 
   /**
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/IRootPrefixFileName.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/IRootPrefixFileName.java
new file mode 100644
index 0000000000..7bbd1a0c9a
--- /dev/null
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/IRootPrefixFileName.java
@@ -0,0 +1,40 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.sftp.provider;
+
+/**
+ * A file name which remembers that its path began with a second slash.
+ *
+ * <p>POSIX gives a path starting with exactly two slashes a meaning of its 
own, and z/OS uses that
+ * meaning: {@code /A.B.C} is a USS path and {@code //A.B.C} is an MVS data 
set. Three or more
+ * slashes are the same as one, again per POSIX.
+ *
+ * <p>The extra slash cannot be kept in the path. Commons VFS normalises a 
path element by element
+ * and drops the empty element a {@code //} amounts to, and it does that again 
on every child
+ * listing and every {@link org.apache.commons.vfs2.FileName#getParent()}, 
because {@code
+ * createName(absPath, type)} is handed an already normalised path. Only the 
root survives being
+ * passed along, which is why the Windows parser hoists a UNC share into the 
root instead of
+ * guarding it in the path. This is the same hoist: the path stays an ordinary 
{@code /A.B.C} and
+ * the prefix rides along in the root.
+ */
+public interface IRootPrefixFileName {
+
+  /**
+   * @return {@code "/"} when the path is rooted at a second slash, {@code ""} 
otherwise
+   */
+  String getRootPrefix();
+}
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileName.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileName.java
new file mode 100644
index 0000000000..f8a648a3c6
--- /dev/null
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileName.java
@@ -0,0 +1,79 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.sftp.provider;
+
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileType;
+import org.apache.commons.vfs2.provider.URLFileName;
+
+/**
+ * The name of a file on an SFTP server, which remembers whether the user 
rooted it at a second
+ * slash: {@code sftp://host/A.B.C} and {@code sftp://host//A.B.C} are two 
different files, and on
+ * z/OS the second is an MVS data set.
+ *
+ * @see IRootPrefixFileName
+ */
+public class SftpFileName extends URLFileName implements IRootPrefixFileName {
+
+  private final String rootPrefix;
+
+  public SftpFileName(
+      String scheme,
+      String hostName,
+      int port,
+      int defaultPort,
+      String userName,
+      String password,
+      String path,
+      FileType type,
+      String queryString,
+      String rootPrefix) {
+    super(scheme, hostName, port, defaultPort, userName, password, path, type, 
queryString);
+    this.rootPrefix = rootPrefix == null ? "" : rootPrefix;
+  }
+
+  @Override
+  public String getRootPrefix() {
+    return rootPrefix;
+  }
+
+  /** Children and parents keep the root they were reached through. */
+  @Override
+  public FileName createName(String absPath, FileType type) {
+    return new SftpFileName(
+        getScheme(),
+        getHostName(),
+        getPort(),
+        getDefaultPort(),
+        getUserName(),
+        getPassword(),
+        absPath,
+        type,
+        getQueryString(),
+        rootPrefix);
+  }
+
+  /**
+   * The extra slash belongs to the root, so it lands between the host and the 
path and the URI
+   * reads back as the user typed it: {@code sftp://host//A.B.C}.
+   */
+  @Override
+  protected void appendRootUri(StringBuilder buffer, boolean addPassword) {
+    super.appendRootUri(buffer, addPassword);
+    buffer.append(rootPrefix);
+  }
+}
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 7d85b65821..ca677bc6a3 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
@@ -16,8 +16,13 @@
  */
 package org.apache.hop.vfs.sftp.provider;
 
+import org.apache.commons.vfs2.FileName;
+import org.apache.commons.vfs2.FileSystemException;
+import org.apache.commons.vfs2.FileType;
 import org.apache.commons.vfs2.provider.FileNameParser;
 import org.apache.commons.vfs2.provider.URLFileNameParser;
+import org.apache.commons.vfs2.provider.UriParser;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
 
 /** Implementation for SFTP. Sets the default port to 22. */
 public class SftpFileNameParser extends URLFileNameParser {
@@ -39,4 +44,87 @@ public class SftpFileNameParser extends URLFileNameParser {
   public SftpFileNameParser() {
     super(DEFAULT_PORT);
   }
+
+  /**
+   * As {@link URLFileNameParser#parseUri}, but a path rooted at a second 
slash keeps it.
+   *
+   * <p>The prefix is taken off before the path is canonicalized and 
normalised, because
+   * normalisation is what would drop it, and it is put in the root of a 
{@link SftpFileName} rather
+   * than left in the path so that it survives every child and every parent 
derived from this name.
+   */
+  @Override
+  public FileName parseUri(VfsComponentContext context, FileName base, String 
fileName)
+      throws FileSystemException {
+    StringBuilder name = new StringBuilder();
+
+    Authority auth = extractToPath(context, fileName, name);
+    String queryString = UriParser.extractQueryString(name);
+    String rootPrefix = resolveRootPrefix(name, fileName, base);
+
+    UriParser.canonicalizePath(name, 0, name.length(), this);
+    UriParser.fixSeparators(name);
+    FileType fileType = UriParser.normalisePath(name);
+
+    return new SftpFileName(
+        auth.getScheme(),
+        auth.getHostName(),
+        auth.getPort(),
+        getDefaultPort(),
+        auth.getUserName(),
+        auth.getPassword(),
+        name.toString(),
+        fileType,
+        queryString,
+        rootPrefix);
+  }
+
+  /**
+   * The prefix for a path, taken from the base name whenever this URI was 
built out of it.
+   *
+   * <p>The textual rule cannot be trusted on a derived name. {@link
+   * org.apache.commons.vfs2.impl.DefaultFileSystemManager#resolveName} 
rebuilds every child, every
+   * parent and every root lookup as {@code base.getRootURI() + path}, and 
since the root URI ends
+   * in a separator and the path begins with one, that concatenation 
manufactures a {@code //} at
+   * the boundary which is indistinguishable from one a user typed. It is also 
the reason Commons
+   * VFS collapses {@code //} at all. A URI built that way starts with the 
root URI of the base it
+   * came from, and the base already knows what it is rooted at, so ask it.
+   *
+   * <p>The one thing this cannot tell apart is a full URI handed to {@code
+   * FileObject.resolveFile(String)} on a file of the same file system, which 
looks exactly like a
+   * derived name. Resolving from a base is for relative paths; an absolute 
URI belongs to {@code
+   * HopVfs.getFileObject}, which has no base and is read as written.
+   *
+   * @param path the path, with the scheme and the authority already taken off
+   * @param uri the whole URI being parsed
+   * @param base the name this one is being derived from, null if there is none
+   */
+  public static String resolveRootPrefix(StringBuilder path, String uri, 
FileName base) {
+    if (base instanceof IRootPrefixFileName prefixed && 
uri.startsWith(base.getRootURI())) {
+      return prefixed.getRootPrefix();
+    }
+    return extractRootPrefix(path);
+  }
+
+  /**
+   * Takes the second slash off a path which starts with exactly two, leaving 
an ordinary absolute
+   * path behind.
+   *
+   * <p>Exactly two is what POSIX gives a meaning of its own and what z/OS 
reads as an MVS data set;
+   * three or more mean the same as one, so those are left for the normaliser 
to collapse. Only a
+   * literal slash counts - a {@code %2F} is an escaped slash, which is to say 
not a separator at
+   * all.
+   *
+   * @param path the path, with the scheme and the authority already taken off
+   * @return {@code "/"} if a second slash was taken off, {@code ""} otherwise
+   */
+  public static String extractRootPrefix(StringBuilder path) {
+    if (path.length() >= 2
+        && path.charAt(0) == '/'
+        && path.charAt(1) == '/'
+        && (path.length() == 2 || path.charAt(2) != '/')) {
+      path.deleteCharAt(0);
+      return "/";
+    }
+    return "";
+  }
 }
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 d7e611126d..b17101d953 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
@@ -104,7 +104,28 @@ public class SftpFileObject extends 
AbstractFileObject<SftpFileSystem> {
   protected SftpFileObject(final AbstractFileName fileName, final 
SftpFileSystem fileSystem)
       throws FileSystemException {
     super(fileName, fileSystem);
-    relPath = 
UriParser.decode(fileSystem.getRootName().getRelativeName(fileName));
+    relPath = remotePath(fileName, fileSystem);
+  }
+
+  /**
+   * The path to ask the server about. Normally relative to the working 
directory of the channel,
+   * which is what every other SFTP server expects.
+   *
+   * <p>A name rooted at a second slash - see {@link IRootPrefixFileName} - 
instead goes out
+   * absolute and with that slash put back, so {@code 
sftp://host//EDIJP.NAB.CRERESP/AAMIPI} asks
+   * for {@code //EDIJP.NAB.CRERESP/AAMIPI} and a z/OS server reads an MVS 
data set rather than a
+   * 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.
+   */
+  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 
UriParser.decode(fileSystem.getRootName().getRelativeName(fileName));
   }
 
   /** Creates this file as a folder. */
@@ -493,9 +514,9 @@ public class SftpFileObject extends 
AbstractFileObject<SftpFileSystem> {
     final ChannelSftp channel = getAbstractFileSystem().getChannel();
     // Using InputStream directly from the channel
     // is much faster than the memory method.
+    // relPath, like every other request: the path of the name has lost a 
second slash.
     try {
-      return new SftpInputStream(
-          channel, channel.get(getName().getPathDecoded(), null, filePointer));
+      return new SftpInputStream(channel, channel.get(relPath, null, 
filePointer));
     } catch (final SftpException e) {
       putChannel(channel);
       throw new FileSystemException(e);
diff --git 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileSystem.java
 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileSystem.java
index da9816d182..54e0f69127 100644
--- 
a/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileSystem.java
+++ 
b/plugins/tech/sftp/src/main/java/org/apache/hop/vfs/sftp/provider/SftpFileSystem.java
@@ -216,8 +216,15 @@ public class SftpFileSystem extends AbstractFileSystem {
         channel.connect(DurationUtils.toMillisInt(connectTimeout));
         final Boolean userDirIsRoot =
             
SftpFileSystemConfigBuilder.getInstance().getUserDirIsRoot(getFileSystemOptions());
+        // A file system rooted at a second slash sends every path absolute, 
so the working
+        // directory plays no part - and a "cd /" is a USS path a z/OS server 
need not have at all.
+        final boolean rootedAtSecondSlash =
+            getRootName() instanceof IRootPrefixFileName prefixed
+                && !prefixed.getRootPrefix().isEmpty();
         final String workingDirectory = getRootName().getPath();
-        if (workingDirectory != null && (userDirIsRoot == null || 
!userDirIsRoot.booleanValue())) {
+        if (workingDirectory != null
+            && !rootedAtSecondSlash
+            && (userDirIsRoot == null || !userDirIsRoot.booleanValue())) {
           try {
             channel.cd(workingDirectory);
           } catch (final SftpException e) {
diff --git 
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpConnectionsTest.java
 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpConnectionsTest.java
index cf85c8d1fb..b540d20523 100644
--- 
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpConnectionsTest.java
+++ 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpConnectionsTest.java
@@ -54,6 +54,27 @@ class SftpConnectionsTest {
         SftpConnections.buildUri(connection, "\\tmp\\upload", "file.txt"));
   }
 
+  /**
+   * Exactly two leading slashes is a path of its own - on z/OS {@code 
//A.B.C} is an MVS data set
+   * and {@code /A.B.C} a USS path - so it reaches the parser as {@code 
prod:////A.B.C}. Three or
+   * more still mean the same as one.
+   */
+  @Test
+  void testBuildUriKeepsAPathRootedAtASecondSlash() {
+    assertEquals(
+        "prod:////EDIJP.NAB.CRERESP/AAMIPI",
+        SftpConnections.buildUri(connection, "//EDIJP.NAB.CRERESP", "AAMIPI"));
+    assertEquals(
+        "prod:////EDIJP.NAB.CRERESP",
+        SftpConnections.buildUri(connection, "//EDIJP.NAB.CRERESP", null));
+    assertEquals(
+        "prod:////EDIJP.NAB.CRERESP/AAMIPI",
+        SftpConnections.buildUri(connection, null, 
"//EDIJP.NAB.CRERESP/AAMIPI"));
+    assertEquals(
+        "prod://tmp/upload/file.txt",
+        SftpConnections.buildUri(connection, "///tmp/upload", "file.txt"));
+  }
+
   private SftpConnection connection() {
     SftpConnection sftpConnection = new SftpConnection();
     sftpConnection.setName("prod");
diff --git 
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpPathShapeTest.java
 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpPathShapeTest.java
new file mode 100644
index 0000000000..889f6e7230
--- /dev/null
+++ 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/SftpPathShapeTest.java
@@ -0,0 +1,189 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.apache.hop.vfs.sftp;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import org.apache.commons.vfs2.FileSystemManager;
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
+import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.junit.vfs.VfsPathShapes;
+import org.apache.hop.vfs.sftp.metadata.SftpConnection;
+import org.apache.hop.vfs.sftp.provider.SftpFileProvider;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Nested;
+import org.junit.jupiter.api.Test;
+
+/**
+ * What the two SFTP file name parsers make of the awkward path shapes in 
{@link VfsPathShapes}.
+ *
+ * <p>Neither parser needs a server: this is name parsing only, so the tables 
below say what a path
+ * turns into before anything is ever sent over the wire.
+ */
+class SftpPathShapeTest {
+
+  private static DefaultFileSystemManager manager;
+
+  @BeforeAll
+  static void setUp() throws Exception {
+    manager = new DefaultFileSystemManager();
+    manager.addProvider("sftp", new SftpFileProvider());
+    manager.addProvider("file", new DefaultLocalFileProvider());
+    manager.init();
+  }
+
+  @AfterAll
+  static void tearDown() {
+    if (manager != null) {
+      manager.close();
+    }
+  }
+
+  /**
+   * The parser behind a plain {@code sftp://} URI. The path of a name is 
always an ordinary
+   * absolute path: a path rooted at a second slash keeps it in the 
<em>root</em>, so none of the
+   * rows below move when one is used.
+   */
+  @Nested
+  @DisplayName("sftp:// URIs")
+  class RawUris {
+
+    private static final String BASE = "sftp://user@host:22";;
+    private final VfsPathShapes.NameParser parser = uri -> 
manager.resolveURI(uri);
+
+    @Test
+    void paths() {
+      VfsPathShapes.assertPaths(
+          BASE,
+          parser,
+          """
+          plain                   /dir/file.txt
+          leading-double-slash    /dir/file.txt    # the data set: the extra 
slash is in the root
+          leading-triple-slash    /dir/file.txt
+          interior-double-slash   /dir/file.txt
+          trailing-slash          /dir
+          root-only               /
+          no-path                 /
+          dot-segment             /dir/file.txt
+          parent-segment          /dir/file.txt
+          parent-above-root       error
+          encoded-slash           /dir/file.txt    # %2F is decoded before it 
is normalised away
+          space                   /dir/my file.txt
+          encoded-space           /dir/my file.txt
+          hash                    /dir/file#1.txt
+          question-mark           /dir/file        # "?1.txt" is taken for a 
URL query
+          plus                    /dir/file+1.txt
+          backslash               /dir/file.txt
+          leading-dots-in-name    /dir/..hidden
+          unicode                 /dir/naïve.txt
+          """);
+    }
+
+    /** The second slash rides in the root, which is what lets it survive 
being derived from. */
+    @Test
+    void theSecondSlashIsInTheRoot() throws Exception {
+      assertEquals("sftp://user@host/";, parser.parse(BASE + 
"/dir/file.txt").getRootURI());
+      assertEquals("sftp://user@host//";, parser.parse(BASE + 
"//dir/file.txt").getRootURI());
+      assertEquals(
+          "sftp://user@host//dir/file.txt";, parser.parse(BASE + 
"//dir/file.txt").getURI());
+    }
+
+    @Test
+    void uriRoundTrips() {
+      VfsPathShapes.assertUriRoundTrips(BASE, parser);
+    }
+  }
+
+  /**
+   * The parser behind a named SFTP connection, which is Hop's own code. The 
two slashes behind the
+   * scheme are the separator, so {@code prod://tmp/x} and {@code 
prod:///tmp/x} both mean {@code
+   * /tmp/x}; a third pair makes {@code prod:////A.B.C} the data set {@code 
//A.B.C}.
+   */
+  @Nested
+  @DisplayName("named connection URIs")
+  class NamedConnection {
+
+    private static final String BASE = "prod:/";
+
+    private final VfsPathShapes.NameParser parser = namedConnectionParser();
+
+    private VfsPathShapes.NameParser namedConnectionParser() {
+      SftpConnection connection = new SftpConnection();
+      connection.setName("prod");
+      connection.setServerName("mainframe.example.com");
+      connection.setServerPort("22");
+      connection.setUsername("hop");
+
+      FileSystemManager fsm = mock(FileSystemManager.class);
+      when(fsm.getSchemes()).thenReturn(new String[] {"prod"});
+      VfsComponentContext context = mock(VfsComponentContext.class);
+      when(context.getFileSystemManager()).thenReturn(fsm);
+
+      SftpConnectionFileNameParser nameParser =
+          new SftpConnectionFileNameParser(new Variables(), connection);
+      return uri -> nameParser.parseUri(context, null, uri);
+    }
+
+    @Test
+    void paths() {
+      VfsPathShapes.assertPaths(
+          BASE,
+          parser,
+          """
+          plain                   /dir/file.txt
+          leading-double-slash    /dir/file.txt    # the data set: the extra 
slash is in the root
+          leading-triple-slash    /dir/file.txt
+          interior-double-slash   /dir/file.txt
+          trailing-slash          /dir
+          root-only               /
+          no-path                 /
+          dot-segment             /dir/file.txt
+          parent-segment          /dir/file.txt
+          parent-above-root       error
+          encoded-slash           /dir/file.txt
+          space                   /dir/my file.txt
+          encoded-space           /dir/my file.txt
+          hash                    /dir/file#1.txt
+          question-mark           /dir/file?1.txt  # kept, unlike the sftp:// 
parser above
+          plus                    /dir/file+1.txt
+          backslash               /dir/file.txt
+          leading-dots-in-name    /dir/..hidden
+          unicode                 /dir/naïve.txt
+          """);
+    }
+
+    /** The server stays out of the URI, so the connection name is the whole 
root. */
+    @Test
+    void theConnectionNameIsTheRoot() throws Exception {
+      assertEquals("prod:///", parser.parse(BASE + 
"/dir/file.txt").getRootURI());
+      assertEquals("prod:///dir/file.txt", parser.parse(BASE + 
"/dir/file.txt").getURI());
+      assertEquals("prod:////", parser.parse(BASE + 
"///dir/file.txt").getRootURI());
+      assertEquals("prod:////dir/file.txt", parser.parse(BASE + 
"///dir/file.txt").getURI());
+    }
+
+    @Test
+    void uriRoundTrips() {
+      VfsPathShapes.assertUriRoundTrips(BASE, parser);
+    }
+  }
+}
diff --git 
a/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionJsonTest.java
 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionJsonTest.java
new file mode 100644
index 0000000000..0119640065
--- /dev/null
+++ 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/metadata/SftpConnectionJsonTest.java
@@ -0,0 +1,100 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *       http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.sftp.metadata;
+
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertNotNull;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+
+import java.nio.file.Path;
+import org.apache.hop.core.Const;
+import org.apache.hop.core.encryption.Encr;
+import org.apache.hop.core.encryption.HopTwoWayPasswordEncoder;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPlugin;
+import org.apache.hop.core.encryption.TwoWayPasswordEncoderPluginType;
+import org.apache.hop.core.plugins.PluginRegistry;
+import org.apache.hop.core.variables.IVariables;
+import org.apache.hop.core.variables.Variables;
+import org.apache.hop.metadata.api.IHopMetadataProvider;
+import org.apache.hop.metadata.util.HopMetadataUtil;
+import org.junit.jupiter.api.BeforeAll;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.io.TempDir;
+
+/**
+ * A connection has to survive the trip through the JSON files it actually 
lives in. The in-memory
+ * provider the other tests use is not the same thing: it hands the object 
straight back, so it
+ * agrees with whatever the class does.
+ */
+class SftpConnectionJsonTest {
+
+  @TempDir private Path metadataFolder;
+
+  private IVariables variables;
+
+  @BeforeAll
+  static void initEncryption() throws Exception {
+    PluginRegistry.getInstance()
+        .registerPluginClass(
+            HopTwoWayPasswordEncoder.class.getName(),
+            TwoWayPasswordEncoderPluginType.class,
+            TwoWayPasswordEncoderPlugin.class);
+    Encr.init("Hop");
+  }
+
+  @BeforeEach
+  void pointAtTheMetadataFolder() {
+    variables = new Variables();
+    variables.setVariable(Const.HOP_METADATA_FOLDER, 
metadataFolder.toString());
+  }
+
+  @Test
+  @DisplayName("Every setting survives a save and a load")
+  void everySettingRoundTrips() throws Exception {
+    SftpConnection connection = connection();
+    connection.setUserDirIsRoot(true);
+    connection.setCompression("zlib");
+
+    SftpConnection loaded = saveAndLoad(connection);
+
+    assertEquals("mainframe.example.com", loaded.getServerName());
+    assertEquals("22", loaded.getServerPort());
+    assertEquals("hop", loaded.getUsername());
+    assertTrue(loaded.isUserDirIsRoot());
+    assertEquals("zlib", loaded.getCompression());
+  }
+
+  private SftpConnection connection() {
+    SftpConnection connection = new SftpConnection();
+    connection.setName("mainframe");
+    connection.setServerName("mainframe.example.com");
+    connection.setServerPort("22");
+    connection.setUsername("hop");
+    return connection;
+  }
+
+  private SftpConnection saveAndLoad(SftpConnection connection) throws 
Exception {
+    IHopMetadataProvider provider = 
HopMetadataUtil.getStandardHopMetadataProvider(variables);
+    provider.getSerializer(SftpConnection.class).save(connection);
+    SftpConnection loaded = 
provider.getSerializer(SftpConnection.class).load(connection.getName());
+    assertNotNull(loaded, "the connection could not be read back");
+    return loaded;
+  }
+}
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
new file mode 100644
index 0000000000..59588fab99
--- /dev/null
+++ 
b/plugins/tech/sftp/src/test/java/org/apache/hop/vfs/sftp/provider/SftpDoubleSlashPathTest.java
@@ -0,0 +1,441 @@
+/*
+ * Licensed to the Apache Software Foundation (ASF) under one or more
+ * contributor license agreements.  See the NOTICE file distributed with
+ * this work for additional information regarding copyright ownership.
+ * The ASF licenses this file to You under the Apache License, Version 2.0
+ * (the "License"); you may not use this file except in compliance with
+ * the License.  You may obtain a copy of the License at
+ *
+ *      https://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package org.apache.hop.vfs.sftp.provider;
+
+import static org.apache.commons.vfs2.VfsTestUtils.getTestDirectoryFile;
+import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
+import static org.junit.jupiter.api.Assertions.assertNotEquals;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import com.jcraft.jsch.TestIdentityRepositoryFactory;
+import java.io.File;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.nio.file.Files;
+import java.nio.file.InvalidPathException;
+import java.nio.file.Path;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.List;
+import org.apache.commons.io.IOUtils;
+import org.apache.commons.vfs2.FileName;
+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.NameScope;
+import org.apache.commons.vfs2.RandomAccessContent;
+import org.apache.commons.vfs2.auth.StaticUserAuthenticator;
+import org.apache.commons.vfs2.impl.DefaultFileSystemConfigBuilder;
+import org.apache.commons.vfs2.impl.DefaultFileSystemManager;
+import org.apache.commons.vfs2.provider.VfsComponentContext;
+import org.apache.commons.vfs2.provider.local.DefaultLocalFileProvider;
+import org.apache.commons.vfs2.provider.local.GenericFileNameParser;
+import org.apache.commons.vfs2.provider.local.LocalFileNameParser;
+import org.apache.commons.vfs2.provider.local.WindowsFileNameParser;
+import org.apache.commons.vfs2.util.RandomAccessMode;
+import org.apache.hop.core.variables.IVariables;
+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.metadata.SftpConnection;
+import org.apache.sshd.common.file.virtualfs.VirtualFileSystemFactory;
+import org.apache.sshd.server.SshServer;
+import org.apache.sshd.server.channel.ChannelSession;
+import org.apache.sshd.server.command.Command;
+import org.apache.sshd.server.keyprovider.SimpleGeneratorHostKeyProvider;
+import org.apache.sshd.sftp.server.SftpSubsystem;
+import org.apache.sshd.sftp.server.SftpSubsystemFactory;
+import org.junit.jupiter.api.AfterAll;
+import org.junit.jupiter.api.BeforeAll;
+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.ValueSource;
+
+/**
+ * A mainframe SFTP server (z/OS) distinguishes a USS path {@code /A.B.C} from 
an MVS data set
+ * {@code //A.B.C}: the leading double slash is part of the name, not 
redundant punctuation. Commons
+ * VFS collapses it, so such a data set can not be addressed from Hop at all.
+ *
+ * <p>The collapse happens in {@link 
org.apache.commons.vfs2.provider.UriParser#normalisePath} which
+ * every {@link org.apache.commons.vfs2.provider.AbstractFileNameParser} 
calls: it walks the path
+ * element by element and "axes" every empty element, which is exactly what a 
{@code //} is. The
+ * local provider escapes this for Windows UNC names ({@code \\host\share}) 
because {@link
+ * org.apache.commons.vfs2.provider.local.WindowsFileNameParser} pulls {@code 
//host/share} out of
+ * the path and into the <em>root prefix</em> before normalisation runs. The 
SFTP name parser has no
+ * such escape: {@link SftpFileNameParser} extends {@link
+ * org.apache.commons.vfs2.provider.URLFileNameParser}, which puts only the 
host and the port in the
+ * root, so the whole {@code //A.B.C} is handed to the normaliser as a path.
+ *
+ * <p>Hop keeps the second slash by hoisting it into the <em>root</em> of the 
file name, the same
+ * escape the Windows parser uses for a UNC share, so the path itself stays an 
ordinary {@code
+ * /A.B.C} and survives every child and every parent derived from it.
+ *
+ * @see <a 
href="https://www.ibm.com/docs/en/zos/latest?topic=ftp-mvs-data-sets";>z/OS: 
naming MVS
+ *     data sets</a>
+ */
+class SftpDoubleSlashPathTest {
+
+  private static final String USER = "testuser";
+  private static final String PASSWORD = "testpass";
+
+  /** The data set the user is after, in the four spellings that were tried. */
+  private static final String DATA_SET = "//EDIJP.NAB.CRERESP/AAMIPI";
+
+  /** The scheme of the named connection pointing at the test server. */
+  private static final String NAMED_CONNECTION = "mainframe";
+
+  /** Raw remote paths as the SFTP server received them, oldest first. */
+  private static final List<String> REQUESTED_PATHS =
+      Collections.synchronizedList(new ArrayList<>());
+
+  private static SshServer sshServer;
+  private static int serverPort;
+  private static DefaultFileSystemManager manager;
+
+  @BeforeAll
+  static void setUp() throws Exception {
+    sshServer = SshServer.setUpDefaultServer();
+    sshServer.setPort(0);
+    final Path hostKey = Files.createTempFile("sshd-double-slash-key", ".ser");
+    hostKey.toFile().deleteOnExit();
+    final SimpleGeneratorHostKeyProvider keyProvider = new 
SimpleGeneratorHostKeyProvider(hostKey);
+    keyProvider.setAlgorithm("RSA");
+    sshServer.setKeyPairProvider(keyProvider);
+    sshServer.setPasswordAuthenticator(
+        (user, pass, session) -> USER.equals(user) && PASSWORD.equals(pass));
+    sshServer.setSubsystemFactories(Collections.singletonList(new 
RecordingSftpSubsystemFactory()));
+    final File homeDir = getTestDirectoryFile();
+    sshServer.setFileSystemFactory(new 
VirtualFileSystemFactory(homeDir.toPath().toAbsolutePath()));
+    sshServer.start();
+    serverPort = sshServer.getPort();
+
+    manager = new DefaultFileSystemManager();
+    manager.addProvider("sftp", new SftpFileProvider());
+    manager.addProvider("file", new DefaultLocalFileProvider());
+    manager.addProvider(
+        NAMED_CONNECTION, new SftpConnectionFileProvider(new Variables(), 
namedConnection()));
+    manager.init();
+  }
+
+  /** A named connection to the test server; the password comes from {@link 
#authOptions()}. */
+  private static SftpConnection namedConnection() {
+    final SftpConnection connection = new SftpConnection();
+    connection.setName(NAMED_CONNECTION);
+    connection.setServerName("localhost");
+    connection.setServerPort(Integer.toString(serverPort));
+    connection.setUsername(USER);
+    return connection;
+  }
+
+  @AfterAll
+  static void tearDown() {
+    if (manager != null) {
+      manager.close();
+    }
+    if (sshServer != null) {
+      final Thread stopper = new Thread(() -> IOUtils.closeQuietly(sshServer), 
"sshd-stop");
+      stopper.setDaemon(true);
+      stopper.start();
+      try {
+        stopper.join(5000);
+      } catch (final InterruptedException e) {
+        Thread.currentThread().interrupt();
+      }
+    }
+  }
+
+  private static String baseUri() {
+    return String.format("sftp://%s@localhost:%d";, USER, serverPort);
+  }
+
+  private static FileSystemOptions authOptions() throws FileSystemException {
+    final FileSystemOptions opts = new FileSystemOptions();
+    DefaultFileSystemConfigBuilder.getInstance()
+        .setUserAuthenticator(opts, new StaticUserAuthenticator(null, USER, 
PASSWORD));
+    final SftpFileSystemConfigBuilder builder = 
SftpFileSystemConfigBuilder.getInstance();
+    builder.setStrictHostKeyChecking(opts, "no");
+    builder.setUserInfo(opts, new TrustEveryoneUserInfo());
+    builder.setIdentityRepositoryFactory(opts, new 
TestIdentityRepositoryFactory());
+    builder.setConnectTimeout(opts, Duration.ofSeconds(60));
+    builder.setSessionTimeout(opts, Duration.ofSeconds(60));
+    return opts;
+  }
+
+  /** Resolve a name through the SFTP provider without connecting to anything. 
*/
+  private static FileName parse(String uri) throws FileSystemException {
+    return manager.resolveURI(uri);
+  }
+
+  /** Ask the server for a file and return every raw path it was asked about. 
*/
+  private static List<String> pathsSentToServer(String uri) throws 
FileSystemException {
+    REQUESTED_PATHS.clear();
+    try (FileObject file = manager.resolveFile(uri, authOptions())) {
+      file.exists();
+    }
+    return new ArrayList<>(REQUESTED_PATHS);
+  }
+
+  @Nested
+  @DisplayName("An ordinary path is left alone")
+  class OrdinaryPaths {
+
+    /**
+     * The path of a name is always an ordinary absolute path, the data set 
spelling included - the
+     * second slash lives in the root, not here. Three slashes, a {@code %2F} 
and a {@code .}
+     * segment all mean the plain USS path.
+     */
+    @ParameterizedTest(name = "{0}")
+    @ValueSource(
+        strings = {
+          "//EDIJP.NAB.CRERESP/AAMIPI",
+          "///EDIJP.NAB.CRERESP/AAMIPI",
+          "/\\/\\/EDIJP.NAB.CRERESP/AAMIPI",
+          "/%2F%2FEDIJP.NAB.CRERESP/AAMIPI",
+          "/./EDIJP.NAB.CRERESP/AAMIPI"
+        })
+    void thePathIsAlwaysAnOrdinaryAbsolutePath(String path) throws Exception {
+      assertEquals("/EDIJP.NAB.CRERESP/AAMIPI", parse(baseUri() + 
path).getPath());
+    }
+
+    /** The relative spelling does not even parse. */
+    @Test
+    void parentOfTheRootIsRejected() {
+      final FileSystemException e =
+          assertThrows(
+              FileSystemException.class, () -> parse(baseUri() + 
"/../EDIJP.NAB.CRERESP/AAMIPI"));
+      assertTrue(
+          e.getMessage().contains("Invalid relative file name"),
+          "unexpected message: " + e.getMessage());
+    }
+
+    /** A named SFTP connection reads the same way. */
+    @Test
+    void namedConnectionReadsTheSameWay() throws Exception {
+      assertEquals("/EDIJP.NAB.CRERESP/AAMIPI", parseNamedConnection("prod:" + 
DATA_SET));
+    }
+
+    /** One slash on the way in is one slash on the wire: nothing gained, 
nothing lost. */
+    @Test
+    void theServerIsAskedForTheSingleSlashPath() throws Exception {
+      final List<String> paths = pathsSentToServer(baseUri() + 
"/read-tests/file1.txt");
+
+      assertTrue(paths.contains("/read-tests/file1.txt"), "paths asked for: " 
+ paths);
+      assertTrue(
+          paths.stream().noneMatch(p -> p.startsWith("//")),
+          "no request should carry a double slash, but got: " + paths);
+    }
+  }
+
+  /**
+   * A path rooted at a second slash keeps it, all the way to the wire. The 
slash rides in the root
+   * of the file name rather than in its path - see {@link 
IRootPrefixFileName}.
+   */
+  @Nested
+  @DisplayName("Addressing a z/OS data set")
+  class MainframeDataSets {
+
+    /** The whole point: the server is asked for the data set, not for the USS 
path. */
+    @Test
+    void theServerIsAskedForTheDataSet() throws Exception {
+      final List<String> paths = pathsSentToServer(baseUri() + 
"//read-tests/file1.txt");
+
+      assertTrue(
+          paths.contains("//read-tests/file1.txt"),
+          "the server should have been asked for a //-path, but got: " + 
paths);
+    }
+
+    /**
+     * The SFTP Put transform builds its target out of a named connection and 
a remote folder, so
+     * the data set has to make it through {@link SftpConnections#buildUri} as 
well.
+     */
+    @Test
+    void aRemoteFolderOfANamedConnectionReachesTheDataSet() throws Exception {
+      final String uri = SftpConnections.buildUri(namedConnection(), 
"//read-tests", "file1.txt");
+      final List<String> paths = pathsSentToServer(uri);
+
+      assertTrue(
+          paths.contains("//read-tests/file1.txt"),
+          "the server should have been asked for a //-path, but got: " + 
paths);
+    }
+
+    /** A random-access read opens the same file a plain read does: the data 
set. */
+    @Test
+    void aRandomAccessReadAsksForTheDataSet() throws Exception {
+      final String expected =
+          Files.readString(
+              new File(getTestDirectoryFile(), 
"read-tests/file1.txt").toPath(),
+              StandardCharsets.UTF_8);
+
+      REQUESTED_PATHS.clear();
+      final String content;
+      try (FileObject file =
+              manager.resolveFile(baseUri() + "//read-tests/file1.txt", 
authOptions());
+          RandomAccessContent randomAccess =
+              file.getContent().getRandomAccessContent(RandomAccessMode.READ);
+          InputStream inputStream = randomAccess.getInputStream()) {
+        content = new String(inputStream.readAllBytes(), 
StandardCharsets.UTF_8);
+      }
+      final List<String> paths = new ArrayList<>(REQUESTED_PATHS);
+
+      assertEquals(expected, content);
+      assertFalse(
+          paths.contains("/read-tests/file1.txt"),
+          "the random-access read went to the single-slash path: " + paths);
+    }
+
+    /** One slash is still one slash: an ordinary path is untouched by any of 
this. */
+    @Test
+    void aSingleSlashIsStillAUssPath() throws Exception {
+      final List<String> paths = pathsSentToServer(baseUri() + 
"/read-tests/file1.txt");
+
+      assertTrue(
+          paths.stream().noneMatch(p -> p.startsWith("//")),
+          "an ordinary path should not have gained a slash, but got: " + 
paths);
+    }
+
+    /** Three or more mean the same as one, as POSIX says they do. */
+    @Test
+    void threeSlashesCollapseToOne() throws Exception {
+      final List<String> paths = pathsSentToServer(baseUri() + 
"///read-tests/file1.txt");
+
+      assertTrue(
+          paths.stream().noneMatch(p -> p.startsWith("//")),
+          "three slashes should have collapsed, but got: " + paths);
+    }
+
+    /** The URI reads back exactly as it was typed, so it survives a save and 
a reload. */
+    @Test
+    void theUriKeepsTheSecondSlash() throws Exception {
+      final FileName name = parse(baseUri() + "//EDIJP.NAB.CRERESP/AAMIPI");
+
+      assertEquals(baseUri() + "//EDIJP.NAB.CRERESP/AAMIPI", name.getURI());
+      assertEquals("/EDIJP.NAB.CRERESP/AAMIPI", name.getPath());
+      assertEquals(baseUri() + "//", name.getRootURI());
+    }
+
+    /** And it is a different file from the USS path of the same name. */
+    @Test
+    void aDataSetIsNotTheUssPath() throws Exception {
+      assertNotEquals(
+          parse(baseUri() + "/EDIJP.NAB.CRERESP").getURI(),
+          parse(baseUri() + "//EDIJP.NAB.CRERESP").getURI());
+    }
+
+    /**
+     * The prefix has to survive being derived from, which is the whole reason 
it lives in the root.
+     */
+    @Test
+    void childrenAndParentsKeepTheSecondSlash() throws Exception {
+      final FileName name = parse(baseUri() + "//EDIJP.NAB.CRERESP/AAMIPI");
+
+      assertEquals(baseUri() + "//EDIJP.NAB.CRERESP", 
name.getParent().getURI());
+      assertEquals(
+          baseUri() + "//EDIJP.NAB.CRERESP/AAMIPI/SUB",
+          manager.resolveName(name, "SUB", NameScope.CHILD).getURI());
+    }
+  }
+
+  /**
+   * 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
+   * Windows name parser hoists {@code //server/share} into the <em>root 
prefix</em> before {@link
+   * org.apache.commons.vfs2.provider.UriParser#normalisePath} ever sees it. 
Take that hoist away
+   * and UNC breaks exactly like the SFTP data set does — which is what the 
generic (non-Windows)
+   * parser below shows.
+   */
+  @Nested
+  @DisplayName("Why UNC survives and a data set does not")
+  class UncKeepsItsDoubleSlash {
+
+    private static final String UNC = "file:////server/share/dir/file.txt";
+
+    /** Hoisted into the root, so it survives, and the URI round-trips with 
all four slashes. */
+    @Test
+    void windowsParserHoistsTheShareIntoTheRoot() throws Exception {
+      final FileName name = parseLocal(new WindowsFileNameParser(), UNC);
+
+      assertEquals("file:////server/share/", name.getRootURI());
+      assertEquals("/dir/file.txt", name.getPath());
+      assertEquals(UNC, name.getURI());
+    }
+
+    /** No hoist, so the slashes end up in the path and the normaliser axes 
them. */
+    @Test
+    void genericParserCollapsesTheShareLikeTheDataSet() throws Exception {
+      final FileName name = parseLocal(new GenericFileNameParser(), UNC);
+
+      assertEquals("file:///", name.getRootURI());
+      assertEquals("/server/share/dir/file.txt", name.getPath());
+      assertEquals("file:///server/share/dir/file.txt", name.getURI());
+    }
+
+    private FileName parseLocal(LocalFileNameParser parser, String uri) throws 
FileSystemException {
+      final FileSystemManager fsm = mock(FileSystemManager.class);
+      when(fsm.getSchemes()).thenReturn(new String[] {"file"});
+      final VfsComponentContext context = mock(VfsComponentContext.class);
+      when(context.getFileSystemManager()).thenReturn(fsm);
+
+      return parser.parseUri(context, null, uri);
+    }
+  }
+
+  /** Parse a {@code <connection>:<path>} URI and return the path it resolves 
to. */
+  private static String parseNamedConnection(String uri) throws 
FileSystemException {
+    final SftpConnection connection = new SftpConnection();
+    connection.setName("prod");
+    connection.setServerName("mainframe.example.com");
+    connection.setServerPort("22");
+    connection.setUsername(USER);
+
+    final IVariables variables = new Variables();
+    final FileSystemManager fsm = mock(FileSystemManager.class);
+    when(fsm.getSchemes()).thenReturn(new String[] {"prod"});
+    final VfsComponentContext context = mock(VfsComponentContext.class);
+    when(context.getFileSystemManager()).thenReturn(fsm);
+
+    return new SftpConnectionFileNameParser(variables, connection)
+        .parseUri(context, null, uri)
+        .getPath();
+  }
+
+  /** An SFTP subsystem that records the raw remote paths the client asks 
about. */
+  private static class RecordingSftpSubsystemFactory extends 
SftpSubsystemFactory {
+
+    @Override
+    public Command createSubsystem(final ChannelSession channel) throws 
IOException {
+      return new SftpSubsystem(channel, this) {
+        @Override
+        protected Path resolveFile(final String remotePath)
+            throws IOException, InvalidPathException {
+          REQUESTED_PATHS.add(remotePath);
+          return super.resolveFile(remotePath);
+        }
+      };
+    }
+  }
+}

Reply via email to