From e24ff44630f4b24c9dd716dd825593095ff2295e Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 8 Oct 2026 13:30:25 +0200 Subject: [PATCH 1/3] Issue #91: public listSubdirectories, structured listFiles entries, follow symbolic links - listFiles returns List (path, size, mtime) instead of a "path;mtime;size" string, lists regular files only, follows symbolic links to regular files (stat of the target) and skips dangling links. File names are no longer trimmed. - New public listSubdirectories(dir, mask): subdirectories whose name matches the mask, symbolic links to directories included. The mask is checked before a link is followed. - The SFTP client of both methods is closed when the listing fails. - Version 1.1.00 (breaking change of listFiles), release workflow publishes automatically (autoRelease: true). Co-Authored-By: Claude Opus 5.5 --- .github/workflows/release.yml | 2 +- pom.xml | 2 +- .../java/org/metricshub/ssh/SshClient.java | 232 ++++++++++++------ .../org/metricshub/ssh/SSHClientTest.java | 214 ++++++++++++++++ 4 files changed, 374 insertions(+), 76 deletions(-) diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 612b361..f2f477a 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -22,6 +22,6 @@ jobs: with: releaseVersion: ${{ inputs.releaseVersion }} developmentVersion: ${{ inputs.developmentVersion }} - autoRelease: false + autoRelease: true jdkVersion: "17" secrets: inherit diff --git a/pom.xml b/pom.xml index 85f3873..0993232 100644 --- a/pom.xml +++ b/pom.xml @@ -9,7 +9,7 @@ ssh-java SSH Java Client - 1.0.05-SNAPSHOT + 1.1.00-SNAPSHOT SSH Client Library for Java diff --git a/src/main/java/org/metricshub/ssh/SshClient.java b/src/main/java/org/metricshub/ssh/SshClient.java index f503e98..b7fe5cb 100644 --- a/src/main/java/org/metricshub/ssh/SshClient.java +++ b/src/main/java/org/metricshub/ssh/SshClient.java @@ -29,6 +29,7 @@ import com.trilead.ssh2.Connection; import com.trilead.ssh2.InteractiveCallback; import com.trilead.ssh2.SCPClient; +import com.trilead.ssh2.SFTPException; import com.trilead.ssh2.SFTPv3Client; import com.trilead.ssh2.SFTPv3DirectoryEntry; import com.trilead.ssh2.SFTPv3FileAttributes; @@ -42,10 +43,10 @@ import java.io.InputStreamReader; import java.io.OutputStream; import java.nio.charset.Charset; +import java.util.ArrayList; import java.util.Arrays; import java.util.List; import java.util.Optional; -import java.util.regex.Matcher; import java.util.regex.Pattern; /** @@ -374,109 +375,192 @@ public long fileSize(final String filePath) throws IOException { } } - private StringBuilder listSubDirectory( + /** + * A regular file listed on the remote system + */ + public static class FileEntry { + + /** + * Path of the file: the listed directory, a slash and the file name + */ + public final String path; + + /** + * Size of the file, in bytes + */ + public final long size; + + /** + * Last modification time of the file, in seconds since the epoch + */ + public final long mtime; + + /** + * Creates a file entry + * + * @param path Path of the file + * @param size Size of the file, in bytes + * @param mtime Last modification time of the file, in seconds since the epoch + */ + public FileEntry(final String path, final long size, final long mtime) { + this.path = path; + this.size = size; + this.mtime = mtime; + } + } + + /** + * Compiles a file name mask, matched case-insensitively with {@link java.util.regex.Matcher#find()}. + * + * @param regExpMask The regular expression, or null or empty to match every name + * @return The compiled mask + */ + private static Pattern compileMask(final String regExpMask) { + return regExpMask != null && !regExpMask.isEmpty() + ? Pattern.compile(regExpMask, Pattern.CASE_INSENSITIVE) + : DEFAULT_MASK_PATTERN; + } + + /** + * Removes the trailing slash of a directory path, so that a file name can be appended after a slash. + * + * @param remoteDirectoryPath The directory path + * @return The path without its trailing slash ("/" becomes an empty string) + */ + private static String stripTrailingSlash(final String remoteDirectoryPath) { + return remoteDirectoryPath.endsWith("/") + ? remoteDirectoryPath.substring(0, remoteDirectoryPath.length() - 1) + : remoteDirectoryPath; + } + + /** + * Returns the attributes of a directory entry, those of its target when the entry is a symbolic link. + * + * @param sftpClient The SFTP client + * @param path The path of the entry + * @param entry The directory entry + * @return The attributes, or null when the entry is a dangling symbolic link or its target cannot be read + * @throws IOException When the communication with the remote host fails + */ + private static SFTPv3FileAttributes followSymlink( + final SFTPv3Client sftpClient, + final String path, + final SFTPv3DirectoryEntry entry + ) throws IOException { + if (!entry.attributes.isSymlink()) { + return entry.attributes; + } + try { + return sftpClient.stat(path); + } catch (SFTPException e) { + return null; + } + } + + private void listSubDirectory( SFTPv3Client sftpClient, String remoteDirectoryPath, Pattern fileMaskPattern, boolean includeSubfolders, - Integer depth, - StringBuilder resultBuilder + int depth, + List entries ) throws IOException { - if (depth <= 15) { - List pathContents = sftpClient.ls(remoteDirectoryPath); + if (depth > 15) { + return; + } - // Fix the remoteDirectoryPath (without the last '/') - if (remoteDirectoryPath.endsWith("/")) { - remoteDirectoryPath = remoteDirectoryPath.substring(0, remoteDirectoryPath.lastIndexOf("/")); + final String directoryPath = stripTrailingSlash(remoteDirectoryPath); + for (SFTPv3DirectoryEntry entry : sftpClient.ls(remoteDirectoryPath)) { + final String filename = entry.filename; + if (filename.equals(".") || filename.equals("..")) { + continue; } - depth++; - for (SFTPv3DirectoryEntry file : pathContents) { - String filename = file.filename.trim(); + final String filePath = directoryPath + "/" + filename; - if (filename.equals(".") || filename.equals("..")) { - continue; - } - - SFTPv3FileAttributes fileAttributes = file.attributes; - String filePath = remoteDirectoryPath + "/" + filename; - - if ((fileAttributes.permissions & 0120000) == 0120000) { - // Symbolic link - continue; + // A directory, but not a symbolic link to one, which could loop + if (entry.attributes.isDirectory()) { + if (includeSubfolders) { + listSubDirectory(sftpClient, filePath, fileMaskPattern, includeSubfolders, depth + 1, entries); } + continue; + } - // CHECKSTYLE:OFF - if ( - ((fileAttributes.permissions & 0100000) == 0100000) || - ((fileAttributes.permissions & 0060000) == 0060000) || - ((fileAttributes.permissions & 0020000) == 0020000) || - ((fileAttributes.permissions & 0140000) == 0140000) - ) { - // Regular/Block/Character/Socket files - final Matcher m = fileMaskPattern.matcher(filename); - if (m.find()) { - resultBuilder - .append(filePath) - .append(";") - .append(fileAttributes.mtime.toString()) - .append(";") - .append(fileAttributes.size.toString()) - .append("\n"); - } - continue; - } - // CHECKSTYLE:ON + if (!fileMaskPattern.matcher(filename).find()) { + continue; + } - if ((fileAttributes.permissions & 0040000) == 0040000) { - // Directory - if (includeSubfolders) { - resultBuilder = - listSubDirectory(sftpClient, filePath, fileMaskPattern, includeSubfolders, depth, resultBuilder); - } - } + final SFTPv3FileAttributes attributes = followSymlink(sftpClient, filePath, entry); + if (attributes != null && attributes.isRegularFile()) { + entries.add(new FileEntry(filePath, attributes.size, attributes.mtime)); } } - - return resultBuilder; } /** - * List the content of the specified directory through the SSH connection - * (using SCP) + * List the regular files of the specified directory through SFTP. Symbolic links to regular files are + * followed: the entry carries the size and modification time of the target. Dangling links are skipped. * * @param remoteDirectoryPath The path to the directory to list on the remote host - * @param regExpFileMask A regular expression that listed files must match with to be listed - * @param includeSubfolders Whether to parse subdirectories as well - * @return The list of files in the specified directory, separated by end-of-lines + * @param regExpFileMask A regular expression that the names of the listed files must contain + * (case-insensitive, {@link java.util.regex.Matcher#find()}); null or empty to list every file + * @param includeSubfolders Whether to list subdirectories as well (symbolic links to directories are not + * followed, and no more than 15 levels are listed) + * @return The files of the specified directory * * @throws IOException When something bad happens while communicating with the remote host * @throws IllegalStateException If called while not yet connected */ - public String listFiles(String remoteDirectoryPath, String regExpFileMask, boolean includeSubfolders) + public List listFiles(String remoteDirectoryPath, String regExpFileMask, boolean includeSubfolders) throws IOException { checkIfAuthenticated(); - // Create an SFTP Client - SFTPv3Client sftpClient = new SFTPv3Client(sshConnection); - - // Prepare the Pattern for fileMask - Pattern fileMaskPattern; - if (regExpFileMask != null && !regExpFileMask.isEmpty()) { - fileMaskPattern = Pattern.compile(regExpFileMask, Pattern.CASE_INSENSITIVE); - } else { - fileMaskPattern = DEFAULT_MASK_PATTERN; + final List entries = new ArrayList<>(); + final SFTPv3Client sftpClient = new SFTPv3Client(sshConnection); + try { + listSubDirectory(sftpClient, remoteDirectoryPath, compileMask(regExpFileMask), includeSubfolders, 1, entries); + } finally { + sftpClient.close(); } + return entries; + } - // Read the directory listing - StringBuilder resultBuilder = new StringBuilder(); - listSubDirectory(sftpClient, remoteDirectoryPath, fileMaskPattern, includeSubfolders, 1, resultBuilder); + /** + * List the subdirectories of the specified directory through SFTP. Symbolic links to directories are + * followed and listed; dangling links are skipped. The mask is checked before a link is followed. + * + * @param remoteDirectoryPath The path to the directory to list on the remote host + * @param regExpMask A regular expression that the names of the listed subdirectories must contain + * (case-insensitive, {@link java.util.regex.Matcher#find()}); null or empty to list every subdirectory + * @return The paths of the subdirectories: the specified directory, a slash and the subdirectory name + * + * @throws IOException When something bad happens while communicating with the remote host + * @throws IllegalStateException If called while not yet connected + */ + public List listSubdirectories(final String remoteDirectoryPath, final String regExpMask) throws IOException { + checkIfAuthenticated(); - // Close the SFTP client - sftpClient.close(); + final Pattern maskPattern = compileMask(regExpMask); + final String directoryPath = stripTrailingSlash(remoteDirectoryPath); + final List subdirectories = new ArrayList<>(); + final SFTPv3Client sftpClient = new SFTPv3Client(sshConnection); + try { + for (SFTPv3DirectoryEntry entry : sftpClient.ls(remoteDirectoryPath)) { + final String name = entry.filename; + if (name.equals(".") || name.equals("..") || !maskPattern.matcher(name).find()) { + continue; + } - // Update the response - return resultBuilder.toString(); + final String path = directoryPath + "/" + name; + final SFTPv3FileAttributes attributes = followSymlink(sftpClient, path, entry); + if (attributes != null && attributes.isDirectory()) { + subdirectories.add(path); + } + } + } finally { + sftpClient.close(); + } + return subdirectories; } /** diff --git a/src/test/java/org/metricshub/ssh/SSHClientTest.java b/src/test/java/org/metricshub/ssh/SSHClientTest.java index 5ff8fd2..cb0c00a 100644 --- a/src/test/java/org/metricshub/ssh/SSHClientTest.java +++ b/src/test/java/org/metricshub/ssh/SSHClientTest.java @@ -2,13 +2,19 @@ import com.trilead.ssh2.ChannelCondition; import com.trilead.ssh2.Connection; +import com.trilead.ssh2.SFTPException; import com.trilead.ssh2.SFTPv3Client; +import com.trilead.ssh2.SFTPv3DirectoryEntry; import com.trilead.ssh2.SFTPv3FileAttributes; import com.trilead.ssh2.Session; import java.io.ByteArrayInputStream; import java.io.ByteArrayOutputStream; import java.io.IOException; +import java.lang.reflect.Constructor; import java.nio.charset.Charset; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; import java.util.Optional; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; @@ -620,4 +626,212 @@ void testFileSize() throws Exception { Assertions.assertEquals(1073741824L, sshClient.fileSize(filePath)); } } + + private static final int REGULAR_FILE = 0100644; + private static final int DIRECTORY = 0040755; + private static final int SYMBOLIC_LINK = 0120777; + private static final int CHARACTER_DEVICE = 0020666; + private static final int BLOCK_DEVICE = 0060660; + private static final int SOCKET = 0140755; + + private static SFTPv3FileAttributes attributes(final int permissions, final long size, final long mtime) { + final SFTPv3FileAttributes attributes = new SFTPv3FileAttributes(); + attributes.permissions = permissions; + attributes.size = size; + attributes.mtime = mtime; + return attributes; + } + + private static SFTPv3DirectoryEntry entry(final String filename, final int permissions) { + return entry(filename, permissions, 0, 0); + } + + private static SFTPv3DirectoryEntry entry( + final String filename, + final int permissions, + final long size, + final long mtime + ) { + final SFTPv3DirectoryEntry entry = new SFTPv3DirectoryEntry(); + entry.filename = filename; + entry.attributes = attributes(permissions, size, mtime); + return entry; + } + + private static SshClient authenticatedClient() { + final Connection sshConnection = Mockito.mock(Connection.class); + Mockito.doReturn(true).when(sshConnection).isAuthenticationComplete(); + final SshClient sshClient = Mockito.spy(new SshClient(HOSTNAME)); + Mockito.doReturn(sshConnection).when(sshClient).getSshConnection(); + return sshClient; + } + + private static SFTPException noSuchFile() throws Exception { + // The constructor is package-private: the server reports SSH_FX_NO_SUCH_FILE (2) + final Constructor constructor = SFTPException.class.getDeclaredConstructor(String.class, int.class); + constructor.setAccessible(true); + return constructor.newInstance("No such file", 2); + } + + private static List paths(final List entries) { + final List paths = new ArrayList<>(); + for (SshClient.FileEntry entry : entries) { + paths.add(entry.path); + } + return paths; + } + + @Test + void testListFiles() throws Exception { + final SFTPException noSuchFile = noSuchFile(); + final List logs = Arrays.asList( + entry(".", DIRECTORY), + entry("..", DIRECTORY), + entry("app.log", REGULAR_FILE, 100, 1000), + entry(" my app;1.LOG ", REGULAR_FILE, 200, 2000), + entry("link.log", SYMBOLIC_LINK), + entry("dangling.log", SYMBOLIC_LINK), + entry("dirlink.log", SYMBOLIC_LINK), + entry("unrelated.lnk", SYMBOLIC_LINK), + entry("tty.log", CHARACTER_DEVICE), + entry("disk.log", BLOCK_DEVICE), + entry("socket.log", SOCKET), + entry("notes.txt", REGULAR_FILE, 50, 500), + entry("sub", DIRECTORY) + ); + + // Directory with a trailing slash, no subfolders + try ( + final MockedConstruction mockedConstruction = Mockito.mockConstruction( + SFTPv3Client.class, + (mock, context) -> { + Mockito.when(mock.ls("/logs/")).thenReturn(logs); + Mockito.when(mock.stat("/logs/link.log")).thenReturn(attributes(REGULAR_FILE, 300, 3000)); + Mockito.when(mock.stat("/logs/dangling.log")).thenThrow(noSuchFile); + Mockito.when(mock.stat("/logs/dirlink.log")).thenReturn(attributes(DIRECTORY, 0, 0)); + } + ) + ) { + // Case-insensitive mask matched with find(); names are kept verbatim (spaces, semicolon) + final List entries = authenticatedClient().listFiles("/logs/", "\\.log", false); + + Assertions.assertEquals(Arrays.asList("/logs/app.log", "/logs/ my app;1.LOG ", "/logs/link.log"), paths(entries)); + Assertions.assertEquals(100, entries.get(0).size); + Assertions.assertEquals(1000, entries.get(0).mtime); + Assertions.assertEquals(200, entries.get(1).size); + // A symbolic link carries the size and modification time of its target + Assertions.assertEquals(300, entries.get(2).size); + Assertions.assertEquals(3000, entries.get(2).mtime); + + final SFTPv3Client sftpClient = mockedConstruction.constructed().get(0); + // A link whose name does not match is never followed, a subfolder is not listed + Mockito.verify(sftpClient, Mockito.never()).stat("/logs/unrelated.lnk"); + Mockito.verify(sftpClient, Mockito.never()).ls("/logs/sub"); + Mockito.verify(sftpClient).close(); + } + + // Subfolders, no mask + try ( + final MockedConstruction mockedConstruction = Mockito.mockConstruction( + SFTPv3Client.class, + (mock, context) -> { + Mockito + .when(mock.ls("/")) + .thenReturn( + Arrays.asList( + entry("top.txt", REGULAR_FILE, 1, 10), + entry("sub", DIRECTORY), + entry("sublink", SYMBOLIC_LINK) + ) + ); + Mockito.when(mock.ls("/sub")).thenReturn(Arrays.asList(entry("nested.log", REGULAR_FILE, 2, 20))); + Mockito.when(mock.stat("/sublink")).thenReturn(attributes(DIRECTORY, 0, 0)); + } + ) + ) { + Assertions.assertEquals( + Arrays.asList("/top.txt", "/sub/nested.log"), + paths(authenticatedClient().listFiles("/", null, true)) + ); + // A symbolic link to a directory is never descended into + Mockito.verify(mockedConstruction.constructed().get(0), Mockito.never()).ls("/sublink"); + } + + // The SFTP client is closed when the listing fails + try ( + final MockedConstruction mockedConstruction = Mockito.mockConstruction( + SFTPv3Client.class, + (mock, context) -> Mockito.when(mock.ls("/missing")).thenThrow(noSuchFile) + ) + ) { + final SshClient sshClient = authenticatedClient(); + Assertions.assertThrows(SFTPException.class, () -> sshClient.listFiles("/missing", null, false)); + Mockito.verify(mockedConstruction.constructed().get(0)).close(); + } + + // Not authenticated + try (final SshClient sshClient = new SshClient(HOSTNAME)) { + Assertions.assertThrows(IllegalStateException.class, () -> sshClient.listFiles("/logs", null, false)); + } + } + + @Test + void testListSubdirectories() throws Exception { + final SFTPException noSuchFile = noSuchFile(); + final List opt = Arrays.asList( + entry(".", DIRECTORY), + entry("..", DIRECTORY), + entry("node1", DIRECTORY), + entry("Node2", SYMBOLIC_LINK), + entry("node3", SYMBOLIC_LINK), + entry("node4", SYMBOLIC_LINK), + entry("node5", REGULAR_FILE), + entry("other", DIRECTORY), + entry("otherlink", SYMBOLIC_LINK) + ); + + try ( + final MockedConstruction mockedConstruction = Mockito.mockConstruction( + SFTPv3Client.class, + (mock, context) -> { + Mockito.when(mock.ls("/opt")).thenReturn(opt); + Mockito.when(mock.ls("/")).thenReturn(opt); + Mockito.when(mock.stat(Mockito.endsWith("Node2"))).thenReturn(attributes(DIRECTORY, 0, 0)); + Mockito.when(mock.stat(Mockito.endsWith("node3"))).thenThrow(noSuchFile); + Mockito.when(mock.stat(Mockito.endsWith("node4"))).thenReturn(attributes(REGULAR_FILE, 0, 0)); + Mockito.when(mock.stat(Mockito.endsWith("otherlink"))).thenReturn(attributes(DIRECTORY, 0, 0)); + } + ) + ) { + final SshClient sshClient = authenticatedClient(); + + // Case-insensitive mask; a link to a directory is listed, a dangling link or a link to a file is not + Assertions.assertEquals(Arrays.asList("/opt/node1", "/opt/Node2"), sshClient.listSubdirectories("/opt", "^node")); + Mockito.verify(mockedConstruction.constructed().get(0), Mockito.never()).stat("/opt/otherlink"); + Mockito.verify(mockedConstruction.constructed().get(0)).close(); + + // No mask, root directory + Assertions.assertEquals( + Arrays.asList("/node1", "/Node2", "/other", "/otherlink"), + sshClient.listSubdirectories("/", "") + ); + } + + // The SFTP client is closed when the listing fails + try ( + final MockedConstruction mockedConstruction = Mockito.mockConstruction( + SFTPv3Client.class, + (mock, context) -> Mockito.when(mock.ls("/missing")).thenThrow(noSuchFile) + ) + ) { + final SshClient sshClient = authenticatedClient(); + Assertions.assertThrows(SFTPException.class, () -> sshClient.listSubdirectories("/missing", null)); + Mockito.verify(mockedConstruction.constructed().get(0)).close(); + } + + // Not authenticated + try (final SshClient sshClient = new SshClient(HOSTNAME)) { + Assertions.assertThrows(IllegalStateException.class, () -> sshClient.listSubdirectories("/opt", null)); + } + } } From 57ee8ddf3e5378faece36a7d804e01ee5bc673d8 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 8 Oct 2026 13:39:15 +0200 Subject: [PATCH 2/3] Issue #91: check that . and .. are skipped and the SFTP client closed when listing subfolders Co-Authored-By: Claude Opus 5.5 --- src/test/java/org/metricshub/ssh/SSHClientTest.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/test/java/org/metricshub/ssh/SSHClientTest.java b/src/test/java/org/metricshub/ssh/SSHClientTest.java index cb0c00a..f573cfc 100644 --- a/src/test/java/org/metricshub/ssh/SSHClientTest.java +++ b/src/test/java/org/metricshub/ssh/SSHClientTest.java @@ -739,6 +739,8 @@ void testListFiles() throws Exception { .when(mock.ls("/")) .thenReturn( Arrays.asList( + entry(".", DIRECTORY), + entry("..", DIRECTORY), entry("top.txt", REGULAR_FILE, 1, 10), entry("sub", DIRECTORY), entry("sublink", SYMBOLIC_LINK) @@ -753,8 +755,12 @@ void testListFiles() throws Exception { Arrays.asList("/top.txt", "/sub/nested.log"), paths(authenticatedClient().listFiles("/", null, true)) ); - // A symbolic link to a directory is never descended into - Mockito.verify(mockedConstruction.constructed().get(0), Mockito.never()).ls("/sublink"); + final SFTPv3Client sftpClient = mockedConstruction.constructed().get(0); + // Neither "." nor ".." nor a symbolic link to a directory is descended into + Mockito.verify(sftpClient, Mockito.never()).ls("/."); + Mockito.verify(sftpClient, Mockito.never()).ls("/.."); + Mockito.verify(sftpClient, Mockito.never()).ls("/sublink"); + Mockito.verify(sftpClient).close(); } // The SFTP client is closed when the listing fails From c3a8c2d93847114990fa9a3eaf0f33ab2e3b6083 Mon Sep 17 00:00:00 2001 From: Bertrand Martin Date: Thu, 8 Oct 2026 13:52:48 +0200 Subject: [PATCH 3/3] Issue #91: release instructions reflect the automatic publication Co-Authored-By: Claude Opus 5.5 --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 8b71869..b3dbd8f 100644 --- a/README.md +++ b/README.md @@ -28,7 +28,7 @@ But it is strongly recommended to only use [GitHub Actions "Release to Maven Cen * Manually trigger the "Release" workflow * Specify the version being released and the next version number (SNAPSHOT) -* Release the corresponding staging repository on [Sonatype's Nexus server](https://s01.oss.sonatype.org/) +* The release is published to Maven Central automatically at the end of the workflow * Merge the PR that has been created to prepare the next version ## License