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/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
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..f573cfc 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,218 @@ 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(".", DIRECTORY),
+ entry("..", DIRECTORY),
+ 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))
+ );
+ 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
+ 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));
+ }
+ }
}