CASSANDRASC-38 Add endpoint to list snapshot files

This commit adds two new endpoints to allow listing snapshot files.

The first endpoint takes a snapshot name as a path parameter, and searches for
all the snapshots matching the provided name. The result lists all the snapshot
files for all matching snapshots. Additionally, secondary index files can be
included in the response by providing the query param
`includeSecondaryIndexFiles=true`.

```
/api/v1/snapshots/:snapshot
```

The second endpoint takes a keyspace, table name, and snapshot name and searches
for a unique snapshot matching the provided snapshot name in the given keyspace
and table name. The results lists the snapshot files matching the given keyspace,
table name, and snapshot name. Similarly to the first endpoint, secondary index
files can be included in the response by providing the query param
`includeSecondaryIndexFiles=true`.

```
/api/v1/keyspace/:keyspace/table/:table/snapshots/:snapshot
```
diff --git a/cassandra-integration-tests/src/test/java/org/apache/cassandra/sidecar/common/testing/CassandraPodException.java b/cassandra-integration-tests/src/test/java/org/apache/cassandra/sidecar/common/testing/CassandraPodException.java
index 5d4fd72..6139a3e 100644
--- a/cassandra-integration-tests/src/test/java/org/apache/cassandra/sidecar/common/testing/CassandraPodException.java
+++ b/cassandra-integration-tests/src/test/java/org/apache/cassandra/sidecar/common/testing/CassandraPodException.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.common.testing;
 
 /**
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/CassandraAdapterDelegate.java b/common/src/main/java/org/apache/cassandra/sidecar/common/CassandraAdapterDelegate.java
index 28751da..21a9c8a 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/CassandraAdapterDelegate.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/CassandraAdapterDelegate.java
@@ -156,7 +156,7 @@
                 adapter = versionProvider.getCassandra(version).create(cqlSession);
                 logger.info("Cassandra version change detected. New adapter loaded: {}", adapter);
             }
-            logger.info("Cassandra version {}", version);
+            logger.debug("Cassandra version {}", version);
         }
         catch (NoHostAvailableException e)
         {
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java
new file mode 100644
index 0000000..647f502
--- /dev/null
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequest.java
@@ -0,0 +1,77 @@
+/*
+ * 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.cassandra.sidecar.common.data;
+
+import org.apache.cassandra.sidecar.common.utils.ValidationUtils;
+
+/**
+ * Holder class for the {@link org.apache.cassandra.sidecar.routes.ListSnapshotFilesHandler}
+ * request parameters
+ */
+public class ListSnapshotFilesRequest extends QualifiedTableName
+{
+    private final String snapshotName;
+    private final boolean includeSecondaryIndexFiles;
+
+    /**
+     * Constructor for the holder class
+     *
+     * @param keyspace                   the keyspace in Cassandra
+     * @param tableName                  the table name in Cassandra
+     * @param snapshotName               the name of the snapshot
+     * @param includeSecondaryIndexFiles true if secondary index files are allowed, false otherwise
+     */
+    public ListSnapshotFilesRequest(String keyspace,
+                                    String tableName,
+                                    String snapshotName,
+                                    boolean includeSecondaryIndexFiles)
+    {
+        super(keyspace, tableName, true);
+        this.snapshotName = ValidationUtils.validateSnapshotName(snapshotName);
+        this.includeSecondaryIndexFiles = includeSecondaryIndexFiles;
+    }
+
+    /**
+     * @return the name of the snapshot
+     */
+    public String getSnapshotName()
+    {
+        return snapshotName;
+    }
+
+    /**
+     * @return true if secondary index files should be included, false otherwise
+     */
+    public boolean includeSecondaryIndexFiles()
+    {
+        return includeSecondaryIndexFiles;
+    }
+
+    /**
+     * {@inheritDoc}
+     */
+    public String toString()
+    {
+        return "ListSnapshotFilesRequest{" +
+               "keyspace='" + getKeyspace() + '\'' +
+               ", tableName='" + getTableName() + '\'' +
+               ", snapshotName='" + snapshotName + '\'' +
+               ", includeSecondaryIndexFiles=" + includeSecondaryIndexFiles +
+               '}';
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesResponse.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesResponse.java
new file mode 100644
index 0000000..5ccd609
--- /dev/null
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesResponse.java
@@ -0,0 +1,121 @@
+/*
+ * 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.cassandra.sidecar.common.data;
+
+import java.nio.file.Paths;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Objects;
+
+import com.fasterxml.jackson.annotation.JsonProperty;
+
+/**
+ * A class representing a response for the {@link ListSnapshotFilesRequest}
+ */
+public class ListSnapshotFilesResponse
+{
+    private final List<FileInfo> snapshotFilesInfo;
+
+    public ListSnapshotFilesResponse()
+    {
+        this.snapshotFilesInfo = new ArrayList<>();
+    }
+
+    public void addSnapshotFile(FileInfo fileInfo)
+    {
+        snapshotFilesInfo.add(fileInfo);
+    }
+
+    public List<FileInfo> getSnapshotFilesInfo()
+    {
+        return snapshotFilesInfo;
+    }
+
+    /**
+     * Json data model of file attributes
+     */
+    public static class FileInfo
+    {
+        public final long size;
+        public final String host;
+        public final int port;
+        public final int dataDirIndex;
+        public final String snapshotName;
+        public final String keySpaceName;
+        public final String tableName;
+        public final String fileName;
+
+        public FileInfo(@JsonProperty("size") long size,
+                        @JsonProperty("host") String host,
+                        @JsonProperty("port") int port,
+                        @JsonProperty("dataDirIndex") int dataDirIndex,
+                        @JsonProperty("snapshotName") String snapshotName,
+                        @JsonProperty("keySpaceName") String keySpaceName,
+                        @JsonProperty("tableName") String tableName,
+                        @JsonProperty("fileName") String fileName)
+        {
+            this.size = size;
+            this.host = host;
+            this.port = port;
+            this.dataDirIndex = dataDirIndex;
+            this.snapshotName = snapshotName;
+            this.keySpaceName = keySpaceName;
+            this.tableName = tableName;
+            this.fileName = fileName;
+        }
+
+        public String ssTableComponentPath()
+        {
+            return Paths.get(keySpaceName, tableName, fileName).toString();
+        }
+
+        public boolean equals(Object o)
+        {
+            if (this == o) return true;
+            if (o == null || getClass() != o.getClass()) return false;
+            FileInfo fileInfo = (FileInfo) o;
+            return size == fileInfo.size &&
+                   port == fileInfo.port &&
+                   dataDirIndex == fileInfo.dataDirIndex &&
+                   Objects.equals(host, fileInfo.host) &&
+                   Objects.equals(snapshotName, fileInfo.snapshotName) &&
+                   Objects.equals(keySpaceName, fileInfo.keySpaceName) &&
+                   Objects.equals(tableName, fileInfo.tableName) &&
+                   Objects.equals(fileName, fileInfo.fileName);
+        }
+
+        public int hashCode()
+        {
+            return Objects.hash(size, host, port, dataDirIndex, snapshotName, keySpaceName, tableName, fileName);
+        }
+    }
+
+    public boolean equals(Object o)
+    {
+        if (this == o) return true;
+        if (o == null || getClass() != o.getClass()) return false;
+        ListSnapshotFilesResponse that = (ListSnapshotFilesResponse) o;
+        return Objects.equals(snapshotFilesInfo, that.snapshotFilesInfo);
+    }
+
+    public int hashCode()
+    {
+        return Objects.hash(snapshotFilesInfo);
+    }
+}
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
index bb2956f..f74c2c6 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/QualifiedTableName.java
@@ -1,3 +1,20 @@
+/*
+ * 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.cassandra.sidecar.common.data;
 
 import org.apache.cassandra.sidecar.common.utils.ValidationUtils;
@@ -18,11 +35,25 @@
      */
     public QualifiedTableName(String keyspace, String tableName)
     {
-        this.keyspace = ValidationUtils.validateKeyspaceName(keyspace);
-        this.tableName = ValidationUtils.validateTableName(tableName);
+        this(keyspace, tableName, true);
     }
 
     /**
+     * Constructs a qualified name with the given {@code keyspace} and {@code tableName}. When {@code required}
+     * is {@code false}, allow constructing the object with {@code null} {@code keyspace}/{@code tableName}.
+     *
+     * @param keyspace  the keyspace in Cassandra
+     * @param tableName the table name in Cassandra
+     * @param required  true if keyspace and table name are required, false if {@code null} is allowed
+     */
+    QualifiedTableName(String keyspace, String tableName, boolean required)
+    {
+        this.keyspace = !required && keyspace == null ? null : ValidationUtils.validateKeyspaceName(keyspace);
+        this.tableName = !required && tableName == null ? null : ValidationUtils.validateTableName(tableName);
+    }
+
+
+    /**
      * @return the keyspace in Cassandra
      */
     public String getKeyspace()
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
index ef9e701..acf9ef2 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/SSTableComponent.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.common.data;
 
 import org.apache.cassandra.sidecar.common.utils.ValidationUtils;
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java b/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
index c6d75fa..84a6b4f 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.common.data;
 
 import org.apache.cassandra.sidecar.common.utils.ValidationUtils;
diff --git a/common/src/main/java/org/apache/cassandra/sidecar/common/utils/ValidationUtils.java b/common/src/main/java/org/apache/cassandra/sidecar/common/utils/ValidationUtils.java
index 345f38c..0933b35 100644
--- a/common/src/main/java/org/apache/cassandra/sidecar/common/utils/ValidationUtils.java
+++ b/common/src/main/java/org/apache/cassandra/sidecar/common/utils/ValidationUtils.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.common.utils;
 
 import java.io.File;
@@ -29,7 +47,17 @@
     private static final String REGEX_COMPONENT = CHARS_ALLOWED_PATTERN + "(.db|.cql|.json|.crc32|TOC.txt)";
     private static final String REGEX_DB_TOC_COMPONENT = CHARS_ALLOWED_PATTERN + "(.db|TOC.txt)";
 
-    public static String validateKeyspaceName(final String keyspace)
+    /**
+     * Validates that the {@code keyspace} is not {@code null}, that it contains valid characters, and that it's
+     * not a forbidden keyspace.
+     *
+     * @param keyspace the name of the Cassandra keyspace to validate
+     * @return the validated {@code keyspace}
+     * @throws NullPointerException when the {@code keyspace} is {@code null}
+     * @throws HttpException        when the {@code keyspace} contains invalid characters in the name or when the
+     *                              keyspace is forbidden
+     */
+    public static String validateKeyspaceName(@NotNull String keyspace)
     {
         Objects.requireNonNull(keyspace, "keyspace must not be null");
         validatePattern(keyspace, "keyspace");
@@ -38,14 +66,32 @@
         return keyspace;
     }
 
-    public static String validateTableName(final String tableName)
+    /**
+     * Validates that the {@code tableName} is not {@code null}, and it contains allowed character for Cassandra
+     * table names.
+     *
+     * @param tableName the name of the Cassandra table to validate
+     * @return the validated {@code tableName}
+     * @throws NullPointerException when the {@code tableName} is {@code null}
+     * @throws HttpException        when the {@code tableName} contains invalid characters in the name
+     */
+    public static String validateTableName(@NotNull String tableName)
     {
         Objects.requireNonNull(tableName, "tableName must not be null");
         validatePattern(tableName, "table name");
         return tableName;
     }
 
-    public static String validateSnapshotName(final String snapshotName)
+    /**
+     * Validates that the {@code snapshotName} is not {@code null}, and it contains allowed character for the
+     * Cassandra snapshot names.
+     *
+     * @param snapshotName the name of the Cassandra snapshot to validate
+     * @return the validated {@code snapshotName}
+     * @throws NullPointerException when the {@code snapshotName} is {@code null}
+     * @throws HttpException        when the {@code snapshotName} contains inalid characters in the name
+     */
+    public static String validateSnapshotName(@NotNull String snapshotName)
     {
         Objects.requireNonNull(snapshotName, "snapshotName must not be null");
         //  most UNIX systems only disallow file separator and null characters for directory names
@@ -55,16 +101,44 @@
         return snapshotName;
     }
 
-    public static String validateComponentName(String componentName)
+    /**
+     * Validates that the {@code componentName} is not {@code null}, and it contains allowed names for the
+     * Cassandra SSTable component.
+     *
+     * @param componentName the name of the SSTable component to validate
+     * @return the validated {@code componentName}
+     * @throws NullPointerException when the {@code componentName} is null
+     * @throws HttpException        when the {@code componentName} is not valid
+     */
+    public static String validateComponentName(@NotNull String componentName)
     {
         return validateComponentNameByRegex(componentName, REGEX_COMPONENT);
     }
 
-    public static String validateDbOrTOCComponentName(String componentName)
+    /**
+     * Validates that the {@code componentName} is not {@code null}, and it is a valid {@code *.db} or {@code *TOC.txt}
+     * Cassandra SSTable component.
+     *
+     * @param componentName the name of the SSTable component to validate
+     * @return the validated {@code componentName}
+     * @throws NullPointerException when the {@code componentName} is null
+     * @throws HttpException        when the {@code componentName} is not a valid {@code *.db} or {@code *TOC.txt}
+     *                              SSTable component
+     */
+    public static String validateDbOrTOCComponentName(@NotNull String componentName)
     {
         return validateComponentNameByRegex(componentName, REGEX_DB_TOC_COMPONENT);
     }
 
+    /**
+     * Validates the {@code componentName} against the provided {@code regex}.
+     *
+     * @param componentName the name of the SSTable component
+     * @param regex         the regex for validation
+     * @return the validated {@code componentName}
+     * @throws NullPointerException when the {@code componentName} is null
+     * @throws HttpException        when the {@code componentName} does not match the provided regex
+     */
     @NotNull
     private static String validateComponentNameByRegex(String componentName, String regex)
     {
@@ -75,6 +149,13 @@
         return componentName;
     }
 
+    /**
+     * Validates that the {@code input} matches the {@code patternWordChars}
+     *
+     * @param input the input
+     * @param name  a name for the exception
+     * @throws HttpException when the {@code input} does not match the pattern
+     */
     private static void validatePattern(String input, String name)
     {
         final Matcher matcher = PATTERN_WORD_CHARS.matcher(input);
diff --git a/common/src/test/java/org/apache/cassandra/sidecar/common/ValidationUtilsTest.java b/common/src/test/java/org/apache/cassandra/sidecar/common/ValidationUtilsTest.java
index c262f86..cc1c54f 100644
--- a/common/src/test/java/org/apache/cassandra/sidecar/common/ValidationUtilsTest.java
+++ b/common/src/test/java/org/apache/cassandra/sidecar/common/ValidationUtilsTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.common;
 
 import org.junit.jupiter.api.Assertions;
@@ -134,7 +152,7 @@
     public void testValidateSnapshotName_validSnapshotNames_expectNoException()
     {
         ValidationUtils.validateSnapshotName("valid-snapshot-name");
-        ValidationUtils.validateSnapshotName("valid\\snapshot\\name"); // Is this really valid ??
+        ValidationUtils.validateSnapshotName("valid\\snapshot\\name");
         ValidationUtils.validateSnapshotName("valid:snapshot:name");
         ValidationUtils.validateSnapshotName("valid$snapshot$name");
         ValidationUtils.validateSnapshotName("valid snapshot name");
diff --git a/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java b/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java
new file mode 100644
index 0000000..a468b57
--- /dev/null
+++ b/common/src/test/java/org/apache/cassandra/sidecar/common/data/ListSnapshotFilesRequestTest.java
@@ -0,0 +1,134 @@
+/*
+ * 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.cassandra.sidecar.common.data;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.ext.web.handler.HttpException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.from;
+
+class ListSnapshotFilesRequestTest
+{
+    @Test
+    void failsWhenKeyspaceIsNull()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest(null, "table", "snapshot", false))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("keyspace must not be null");
+    }
+
+    @Test
+    void failsWhenKeyspaceContainsInvalidCharacters()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("i_❤_u", "table", "snapshot", false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in keyspace: i_❤_u", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenKeyspaceContainsPathTraversalAttack()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("../../../etc/passwd", "table", "snapshot", false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in keyspace: ../../../etc/passwd", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "system_schema", "system_traces", "system_distributed", "system", "system_auth",
+                             "system_views", "system_virtual_schema" })
+    void failsWhenKeyspaceIsForbidden(String forbiddenKeyspace)
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest(forbiddenKeyspace, "table", "snapshot", false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Forbidden")
+        .returns(HttpResponseStatus.FORBIDDEN.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Forbidden keyspace: " + forbiddenKeyspace, from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenTableNameIsNull()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("ks", null, "snapshot", true))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("tableName must not be null");
+    }
+
+    @Test
+    void failsWhenTableNameContainsInvalidCharacters()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("ks", "i_❤_u", "snapshot", false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in table name: i_❤_u", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenTableNameContainsPathTraversalAttack()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("ks", "../../../etc/passwd", "snapshot", false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in table name: ../../../etc/passwd", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenSnapshotNameIsNull()
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("ks", "table", null, false))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("snapshotName must not be null");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "slash/is-not-allowed", "null-char\0-is-not-allowed", "../../../etc/passwd" })
+    void failsWhenSnapshotNameContainsInvalidCharacters(String invalidFileName)
+    {
+        assertThatThrownBy(() -> new ListSnapshotFilesRequest("ks", "table", invalidFileName, false))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in snapshot name: " + invalidFileName,
+                 from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void testValidRequest()
+    {
+        ListSnapshotFilesRequest request = new ListSnapshotFilesRequest("ks", "table", "snapshot", false);
+
+        assertThat(request.getKeyspace()).isEqualTo("ks");
+        assertThat(request.getTableName()).isEqualTo("table");
+        assertThat(request.getSnapshotName()).isEqualTo("snapshot");
+        assertThat(request.includeSecondaryIndexFiles()).isFalse();
+        assertThat(request.toString()).isEqualTo("ListSnapshotFilesRequest{keyspace='ks', tableName='table', " +
+                                                 "snapshotName='snapshot', includeSecondaryIndexFiles=false}");
+    }
+}
diff --git a/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java b/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java
new file mode 100644
index 0000000..df3d726
--- /dev/null
+++ b/common/src/test/java/org/apache/cassandra/sidecar/common/data/StreamSSTableComponentRequestTest.java
@@ -0,0 +1,156 @@
+/*
+ * 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.cassandra.sidecar.common.data;
+
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.ext.web.handler.HttpException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.assertj.core.api.Assertions.from;
+
+class StreamSSTableComponentRequestTest
+{
+    @Test
+    void failsWhenKeyspaceIsNull()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest(null, "table", "snapshot", "component"))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("keyspace must not be null");
+    }
+
+    @Test
+    void failsWhenKeyspaceContainsInvalidCharacters()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("i_❤_u", "table", "snapshot", "component"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in keyspace: i_❤_u", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenKeyspaceContainsPathTraversalAttack()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("../../../etc/passwd", "table",
+                                                                   "snapshot", "component"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in keyspace: ../../../etc/passwd", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "system_schema", "system_traces", "system_distributed", "system", "system_auth",
+                             "system_views", "system_virtual_schema" })
+    void failsWhenKeyspaceIsForbidden(String forbiddenKeyspace)
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest(forbiddenKeyspace, "table", "snapshot", "component"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Forbidden")
+        .returns(HttpResponseStatus.FORBIDDEN.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Forbidden keyspace: " + forbiddenKeyspace, from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenTableNameIsNull()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", null, "snapshot", "component"))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("tableName must not be null");
+    }
+
+    @Test
+    void failsWhenTableNameContainsInvalidCharacters()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "i_❤_u", "snapshot", "component"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in table name: i_❤_u", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenTableNameContainsPathTraversalAttack()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "../../../etc/passwd",
+                                                                   "snapshot", "component"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in table name: ../../../etc/passwd", from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenSnapshotNameIsNull()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "table", null, "component.db"))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("snapshotName must not be null");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "slash/is-not-allowed", "null-char\0-is-not-allowed", "../../../etc/passwd" })
+    void failsWhenSnapshotNameContainsInvalidCharacters(String invalidFileName)
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "table", invalidFileName, "component.db"))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid characters in snapshot name: " + invalidFileName,
+                 from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void failsWhenComponentNameIsNull()
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "table", "snapshot", null))
+        .isInstanceOf(NullPointerException.class)
+        .hasMessageContaining("componentName must not be null");
+    }
+
+    @ParameterizedTest
+    @ValueSource(strings = { "i_❤_u.db", "this-is-not-allowed.jar", "../../../etc/passwd.db" })
+    void failsWhenComponentNameContainsInvalidCharacters(String invalidComponentName)
+    {
+        assertThatThrownBy(() -> new StreamSSTableComponentRequest("ks", "table", "snapshot", invalidComponentName))
+        .isInstanceOf(HttpException.class)
+        .hasMessageContaining("Bad Request")
+        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
+        .returns("Invalid component name: " + invalidComponentName, from(t -> ((HttpException) t).getPayload()));
+    }
+
+    @Test
+    void testValidRequest()
+    {
+        StreamSSTableComponentRequest req =
+        new StreamSSTableComponentRequest("ks", "table", "snapshot", "data.db");
+
+        assertThat(req.getKeyspace()).isEqualTo("ks");
+        assertThat(req.getTableName()).isEqualTo("table");
+        assertThat(req.getSnapshotName()).isEqualTo("snapshot");
+        assertThat(req.getComponentName()).isEqualTo("data.db");
+        assertThat(req.toString()).isEqualTo("StreamSSTableComponentRequest{keyspace='ks', tableName='table', " +
+                                             "snapshot='snapshot', componentName='data.db'}");
+    }
+}
diff --git a/spotbugs-exclude.xml b/spotbugs-exclude.xml
index dffa12d..12c771b 100644
--- a/spotbugs-exclude.xml
+++ b/spotbugs-exclude.xml
@@ -11,4 +11,10 @@
         <Bug pattern="RCN_REDUNDANT_NULLCHECK_WOULD_HAVE_BEEN_A_NPE"/>
     </Match>
 
+    <!-- Ignore RV_RETURN_VALUE_IGNORED_BAD_PRACTICE for directory/file creation during test setup -->
+    <Match>
+        <Class name="org.apache.cassandra.sidecar.snapshots.AbstractSnapshotPathBuilderTest" />
+        <Bug pattern="RV_RETURN_VALUE_IGNORED_BAD_PRACTICE" />
+    </Match>
+
 </FindBugsFilter>
\ No newline at end of file
diff --git a/src/main/java/com/google/common/util/concurrent/SidecarRateLimiter.java b/src/main/java/com/google/common/util/concurrent/SidecarRateLimiter.java
index 4a1bd79..01ee9ff 100644
--- a/src/main/java/com/google/common/util/concurrent/SidecarRateLimiter.java
+++ b/src/main/java/com/google/common/util/concurrent/SidecarRateLimiter.java
@@ -1,3 +1,21 @@
+/*
+ * 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 com.google.common.util.concurrent;
 
 /**
diff --git a/src/main/java/org/apache/cassandra/sidecar/MainModule.java b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
index 2c914d5..8767697 100644
--- a/src/main/java/org/apache/cassandra/sidecar/MainModule.java
+++ b/src/main/java/org/apache/cassandra/sidecar/MainModule.java
@@ -41,7 +41,6 @@
 import com.google.inject.Singleton;
 import io.vertx.core.Vertx;
 import io.vertx.core.VertxOptions;
-import io.vertx.core.http.HttpMethod;
 import io.vertx.core.http.HttpServer;
 import io.vertx.core.http.HttpServerOptions;
 import io.vertx.core.net.JksOptions;
@@ -60,6 +59,7 @@
 import org.apache.cassandra.sidecar.routes.CassandraHealthService;
 import org.apache.cassandra.sidecar.routes.FileStreamHandler;
 import org.apache.cassandra.sidecar.routes.HealthService;
+import org.apache.cassandra.sidecar.routes.ListSnapshotFilesHandler;
 import org.apache.cassandra.sidecar.routes.StreamSSTableComponentHandler;
 import org.apache.cassandra.sidecar.routes.SwaggerOpenApiResource;
 import org.apache.cassandra.sidecar.utils.YAMLKeyConstants;
@@ -73,7 +73,7 @@
 public class MainModule extends AbstractModule
 {
     private static final Logger logger = LoggerFactory.getLogger(MainModule.class);
-    private static final String V1_API_VERSION = "/api/v1";
+    private static final String API_V1_VERSION = "/api/v1";
 
     @Provides
     @Singleton
@@ -134,6 +134,7 @@
     public Router vertxRouter(Vertx vertx,
                               StreamSSTableComponentHandler streamSSTableComponentHandler,
                               FileStreamHandler fileStreamHandler,
+                              ListSnapshotFilesHandler listSnapshotFilesHandler,
                               LoggerHandler loggerHandler,
                               ErrorHandler errorHandler)
     {
@@ -152,17 +153,13 @@
 
         // add custom routers
         final String componentRoute = "/keyspace/:keyspace/table/:table/snapshots/:snapshot/component/:component";
-        final String defaultStreamRoute = V1_API_VERSION + componentRoute;
-        final String instanceSpecificStreamRoute = V1_API_VERSION + "/instance/:instanceId" + componentRoute;
-        router.route().method(HttpMethod.GET)
-              .path(defaultStreamRoute)
-              .handler(streamSSTableComponentHandler::handleAllRequests)
+        router.get(API_V1_VERSION + componentRoute)
+              .handler(streamSSTableComponentHandler)
               .handler(fileStreamHandler);
 
-        router.route().method(HttpMethod.GET)
-              .path(instanceSpecificStreamRoute)
-              .handler(streamSSTableComponentHandler::handlePerInstanceRequests)
-              .handler(fileStreamHandler);
+        final String listSnapshotFilesRoute = "/keyspace/:keyspace/table/:table/snapshots/:snapshot";
+        router.get(API_V1_VERSION + listSnapshotFilesRoute)
+              .handler(listSnapshotFilesHandler);
 
         return router;
     }
diff --git a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java b/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java
index a4a295c..ad16849 100644
--- a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java
+++ b/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfig.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.cluster;
 
 import java.util.List;
diff --git a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfigImpl.java b/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfigImpl.java
index 189e820..d067580 100644
--- a/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfigImpl.java
+++ b/src/main/java/org/apache/cassandra/sidecar/cluster/InstancesConfigImpl.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.cluster;
 
 import java.util.List;
@@ -33,19 +51,21 @@
 
     public InstanceMetadata instanceFromId(int id)
     {
-        if (!idToInstanceMetas.containsKey(id))
+        InstanceMetadata instanceMetadata = idToInstanceMetas.get(id);
+        if (instanceMetadata == null)
         {
             throw new IllegalArgumentException("Instance id " + id + " not found");
         }
-        return idToInstanceMetas.get(id);
+        return instanceMetadata;
     }
 
     public InstanceMetadata instanceFromHost(String host)
     {
-        if (!hostToInstanceMetas.containsKey(host))
+        InstanceMetadata instanceMetadata = hostToInstanceMetas.get(host);
+        if (instanceMetadata == null)
         {
             throw new IllegalArgumentException("Instance with host address " + host + " not found");
         }
-        return hostToInstanceMetas.get(host);
+        return instanceMetadata;
     }
 }
diff --git a/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java b/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
index 2f53175..444e999 100644
--- a/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
+++ b/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadata.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.cluster.instance;
 
 import java.util.List;
diff --git a/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java b/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
index 8f3b566..7e7d31b 100644
--- a/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
+++ b/src/main/java/org/apache/cassandra/sidecar/cluster/instance/InstanceMetadataImpl.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.cluster.instance;
 
 import java.util.List;
diff --git a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java b/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
index 8ea135f..3dc783c 100644
--- a/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
+++ b/src/main/java/org/apache/cassandra/sidecar/exceptions/RangeException.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.exceptions;
 
 /**
diff --git a/src/main/java/org/apache/cassandra/sidecar/models/HttpResponse.java b/src/main/java/org/apache/cassandra/sidecar/models/HttpResponse.java
index ffeebf5..a7ed63f 100644
--- a/src/main/java/org/apache/cassandra/sidecar/models/HttpResponse.java
+++ b/src/main/java/org/apache/cassandra/sidecar/models/HttpResponse.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.models;
 
 import io.netty.handler.codec.http.HttpHeaderNames;
diff --git a/src/main/java/org/apache/cassandra/sidecar/models/Range.java b/src/main/java/org/apache/cassandra/sidecar/models/Range.java
index 411a2a6..b005064 100644
--- a/src/main/java/org/apache/cassandra/sidecar/models/Range.java
+++ b/src/main/java/org/apache/cassandra/sidecar/models/Range.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.models;
 
 import java.util.Objects;
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java
new file mode 100644
index 0000000..4331ce1
--- /dev/null
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/AbstractHandler.java
@@ -0,0 +1,84 @@
+/*
+ * 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.cassandra.sidecar.routes;
+
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.core.Handler;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.handler.HttpException;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+
+import static org.apache.cassandra.sidecar.utils.RequestUtils.extractHostAddressWithoutPort;
+
+/**
+ * An abstract {@link Handler<RoutingContext>} that provides common functionality for handler
+ * implementations.
+ */
+public abstract class AbstractHandler implements Handler<RoutingContext>
+{
+    protected static final String INSTANCE_ID = "instanceId";
+
+    protected final InstancesConfig instancesConfig;
+
+    /**
+     * Constructs a handler with the provided {@code instancesConfig}
+     *
+     * @param instancesConfig the instances configuration
+     */
+    protected AbstractHandler(InstancesConfig instancesConfig)
+    {
+        this.instancesConfig = instancesConfig;
+    }
+
+    /**
+     * Returns the host from the path if the requests contains the {@code /instance/} path parameter,
+     * otherwise it returns the host parsed from the request.
+     *
+     * @param context the routing context
+     * @return the host for the routing context
+     * @throws HttpException when the {@code /instance/} path parameter is {@code null}
+     */
+    public String getHost(RoutingContext context)
+    {
+        if (context.request().params().contains(INSTANCE_ID))
+        {
+            String instanceIdParam = context.request().getParam(INSTANCE_ID);
+            if (instanceIdParam == null)
+            {
+                throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
+                                        "InstanceId query parameter must be provided");
+            }
+
+            try
+            {
+                int instanceId = Integer.parseInt(instanceIdParam);
+                return instancesConfig.instanceFromId(instanceId).host();
+            }
+            catch (NumberFormatException ex)
+            {
+                throw new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
+                                        "InstanceId query parameter must be a valid integer");
+            }
+        }
+        else
+        {
+            return extractHostAddressWithoutPort(context.request().host());
+        }
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthService.java b/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthService.java
index 5c71224..6f1cd31 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthService.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/CassandraHealthService.java
@@ -22,6 +22,7 @@
 import javax.ws.rs.Path;
 import javax.ws.rs.PathParam;
 import javax.ws.rs.Produces;
+import javax.ws.rs.QueryParam;
 import javax.ws.rs.core.Context;
 import javax.ws.rs.core.MediaType;
 import javax.ws.rs.core.Response;
@@ -64,10 +65,18 @@
     })
     @GET
     @Path("/v1/cassandra/__health")
-    public Response getCassandraHealth(@Context HttpServerRequest req)
+    public Response getCassandraHealth(@Context HttpServerRequest req,
+                                       @QueryParam(AbstractHandler.INSTANCE_ID) Integer instanceId)
     {
-        final String host = req.host();
-        final CassandraAdapterDelegate cassandra = metadataFetcher.getDelegate(extractHostAddressWithoutPort(host));
+        CassandraAdapterDelegate cassandra;
+        if (instanceId != null)
+        {
+            cassandra = metadataFetcher.getDelegate(instanceId);
+        }
+        else
+        {
+            cassandra = metadataFetcher.getDelegate(extractHostAddressWithoutPort(req.host()));
+        }
         return getHealthResponse(cassandra);
     }
 
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java
index b0c02b8..bf3beee 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/FileStreamHandler.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.routes;
 
 import org.slf4j.Logger;
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java
new file mode 100644
index 0000000..42166c9
--- /dev/null
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandler.java
@@ -0,0 +1,171 @@
+/*
+ * 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.cassandra.sidecar.routes;
+
+import java.io.FileNotFoundException;
+import java.nio.file.NoSuchFileException;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.util.List;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.Inject;
+import io.netty.handler.codec.http.HttpResponseStatus;
+import io.vertx.core.file.FileProps;
+import io.vertx.core.http.HttpServerRequest;
+import io.vertx.core.net.SocketAddress;
+import io.vertx.ext.web.RoutingContext;
+import io.vertx.ext.web.handler.HttpException;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesRequest;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+import org.apache.cassandra.sidecar.snapshots.SnapshotPathBuilder;
+
+/**
+ * ListSnapshotFilesHandler class lists paths of all the snapshot files of a given snapshot name.
+ * Query param includeSecondaryIndexFiles is used to request secondary index files along with other files
+ * For example:
+ *
+ * <p>
+ * /api/v1/snapshots/testSnapshot                                    lists all SSTable component files for all the
+ * "testSnapshot" snapshots
+ * <p>
+ * /api/v1/snapshots/testSnapshot?includeSecondaryIndexFiles=true    lists all SSTable component files including
+ * secondary index files for all the "testSnapshot"
+ * snapshots
+ * <p>
+ * /api/v1/keyspace/ks/table/tbl/snapshots/testSnapshot              lists all SSTable component files for the
+ * "testSnapshot" snapshot for the "ks" keyspace
+ * and the "tbl" table
+ * <p>
+ * /api/v1/keyspace/ks/table/tbl/snapshots/testSnapshot?includeSecondaryIndexFiles=true
+ * lists all SSTable component files including
+ * secondary index files for the "testSnapshot"
+ * snapshot for the "ks" keyspace and the "tbl"
+ * table
+ */
+public class ListSnapshotFilesHandler extends AbstractHandler
+{
+    private static final Logger logger = LoggerFactory.getLogger(ListSnapshotFilesHandler.class);
+    private static final String INCLUDE_SECONDARY_INDEX_FILES = "includeSecondaryIndexFiles";
+    private static final int DATA_DIR_INDEX = 0;
+    private static final int TABLE_NAME_SUBPATH_INDEX = 1;
+    private static final int FILE_NAME_SUBPATH_INDEX = 4;
+    private final SnapshotPathBuilder builder;
+
+    @Inject
+    public ListSnapshotFilesHandler(SnapshotPathBuilder builder, InstancesConfig instancesConfig)
+    {
+        super(instancesConfig);
+        this.builder = builder;
+    }
+
+    @Override
+    public void handle(RoutingContext context)
+    {
+        final HttpServerRequest request = context.request();
+        final String host = getHost(context);
+        final SocketAddress remoteAddress = request.remoteAddress();
+        final ListSnapshotFilesRequest requestParams = extractParamsOrThrow(context);
+        logger.debug("ListSnapshotFilesHandler received request: {} from: {}. Instance: {}",
+                     requestParams, remoteAddress, host);
+
+        boolean secondaryIndexFiles = requestParams.includeSecondaryIndexFiles();
+
+        builder.build(host, requestParams)
+               .compose(directory -> builder.listSnapshotDirectory(directory, secondaryIndexFiles))
+               .onSuccess(fileList ->
+                          {
+                              if (fileList.isEmpty())
+                              {
+                                  String payload = "Snapshot '" + requestParams.getSnapshotName() + "' not found";
+                                  context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(), payload));
+                              }
+                              else
+                              {
+                                  logger.debug("ListSnapshotFilesHandler handled {} for {}. Instance: {}",
+                                               requestParams, remoteAddress, host);
+                                  context.json(buildResponse(host, requestParams, fileList));
+                              }
+                          })
+               .onFailure(cause ->
+                          {
+                              logger.error("ListSnapshotFilesHandler failed for request: {} from: {}. Instance: {}",
+                                           requestParams, remoteAddress, host);
+                              if (cause instanceof FileNotFoundException ||
+                                  cause instanceof NoSuchFileException)
+                              {
+                                  context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(),
+                                                                 cause.getMessage()));
+                              }
+                              else
+                              {
+                                  context.fail(new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
+                                                                 "Invalid request for " + requestParams));
+                              }
+                          });
+    }
+
+    private ListSnapshotFilesResponse buildResponse(String host,
+                                                    ListSnapshotFilesRequest request,
+                                                    List<Pair<String, FileProps>> fileList)
+    {
+        InstanceMetadata instanceMetadata = instancesConfig.instanceFromHost(host);
+        int sidecarPort = instanceMetadata.port();
+        Path dataDirPath = Paths.get(instanceMetadata.dataDirs().get(DATA_DIR_INDEX));
+        ListSnapshotFilesResponse response = new ListSnapshotFilesResponse();
+        String snapshotName = request.getSnapshotName();
+
+        for (Pair<String, FileProps> file : fileList)
+        {
+            Path pathFromDataDir = dataDirPath.relativize(Paths.get(file.getLeft()));
+
+            String keyspace = request.getKeyspace();
+            // table name might include a dash (-) with the table UUID so we always use it as part of the response
+            String tableName = pathFromDataDir.getName(TABLE_NAME_SUBPATH_INDEX).toString();
+            String fileName = pathFromDataDir.getName(FILE_NAME_SUBPATH_INDEX).toString();
+
+            response.addSnapshotFile(new ListSnapshotFilesResponse.FileInfo(file.getRight().size(),
+                                                                            host,
+                                                                            sidecarPort,
+                                                                            DATA_DIR_INDEX,
+                                                                            snapshotName,
+                                                                            keyspace,
+                                                                            tableName,
+                                                                            fileName));
+        }
+        return response;
+    }
+
+    private ListSnapshotFilesRequest extractParamsOrThrow(final RoutingContext context)
+    {
+        boolean includeSecondaryIndexFiles =
+        "true".equalsIgnoreCase(context.request().getParam(INCLUDE_SECONDARY_INDEX_FILES, "false"));
+
+        return new ListSnapshotFilesRequest(context.pathParam("keyspace"),
+                                            context.pathParam("table"),
+                                            context.pathParam("snapshot"),
+                                            includeSecondaryIndexFiles
+        );
+    }
+}
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java b/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java
index 517baac..27fdb79 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandler.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.routes;
 
 import java.io.FileNotFoundException;
@@ -16,76 +34,58 @@
 import org.apache.cassandra.sidecar.common.data.StreamSSTableComponentRequest;
 import org.apache.cassandra.sidecar.snapshots.SnapshotPathBuilder;
 
-import static org.apache.cassandra.sidecar.utils.RequestUtils.extractHostAddressWithoutPort;
-
 /**
  * This handler validates that the component exists in the cluster and sets up the context
  * for the {@link FileStreamHandler} to stream the component back to the client
  */
 @Singleton
-public class StreamSSTableComponentHandler
+public class StreamSSTableComponentHandler extends AbstractHandler
 {
     private static final Logger logger = LoggerFactory.getLogger(StreamSSTableComponentHandler.class);
 
     private final SnapshotPathBuilder snapshotPathBuilder;
-    private final InstancesConfig instancesConfig;
 
     @Inject
     public StreamSSTableComponentHandler(SnapshotPathBuilder snapshotPathBuilder, InstancesConfig instancesConfig)
     {
+        super(instancesConfig);
         this.snapshotPathBuilder = snapshotPathBuilder;
-        this.instancesConfig = instancesConfig;
     }
 
-    public void handleAllRequests(RoutingContext context)
+    @Override
+    public void handle(RoutingContext context)
     {
         final HttpServerRequest request = context.request();
-        final String host = extractHostAddressWithoutPort(request.host());
-        streamFilesForHost(host, context);
-    }
-
-    public void handlePerInstanceRequests(RoutingContext context)
-    {
-        final String instanceIdParam = context.request().getParam("InstanceId");
-        if (instanceIdParam == null)
-        {
-            context.fail(new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
-                                           "InstanceId path parameter must be provided"));
-            return;
-        }
-
-        final Integer instanceId = Integer.valueOf(instanceIdParam);
-        final String host = instancesConfig.instanceFromId(instanceId).host();
-        streamFilesForHost(host, context);
-    }
-
-    public void streamFilesForHost(String host, RoutingContext context)
-    {
-        final SocketAddress remoteAddress = context.request().remoteAddress();
+        final String host = getHost(context);
+        final SocketAddress remoteAddress = request.remoteAddress();
         final StreamSSTableComponentRequest requestParams = extractParamsOrThrow(context);
-        logger.info("StreamSSTableComponentHandler received request: {} from: {}. Instance: {}", requestParams,
-                    remoteAddress, host);
+        logger.debug("StreamSSTableComponentHandler received request: {} from: {}. Instance: {}", requestParams,
+                     remoteAddress, host);
 
         snapshotPathBuilder.build(host, requestParams)
                            .onSuccess(path ->
-               {
-                   logger.debug("StreamSSTableComponentHandler handled {} for client {}. Instance: {}", path,
-                                remoteAddress, host);
-                   context.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, path)
-                          .next();
-               })
+                           {
+                               logger.debug("StreamSSTableComponentHandler handled {} for client {}. Instance: {}",
+                                            path, remoteAddress, host);
+                               context.put(FileStreamHandler.FILE_PATH_CONTEXT_KEY, path)
+                                      .next();
+                           })
                            .onFailure(cause ->
-               {
-                   if (cause instanceof FileNotFoundException)
-                   {
-                       context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(), cause.getMessage()));
-                   }
-                   else
-                   {
-                       context.fail(new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
-                                                      "Invalid request for " + requestParams));
-                   }
-               });
+                           {
+                               String errMsg =
+                               "StreamSSTableComponentHandler failed for request: {} from: {}. Instance: {}";
+                               logger.error(errMsg, requestParams, remoteAddress, host);
+                               if (cause instanceof FileNotFoundException)
+                               {
+                                   context.fail(new HttpException(HttpResponseStatus.NOT_FOUND.code(),
+                                                                  cause.getMessage()));
+                               }
+                               else
+                               {
+                                   context.fail(new HttpException(HttpResponseStatus.BAD_REQUEST.code(),
+                                                                  "Invalid request for " + requestParams));
+                               }
+                           });
     }
 
     private StreamSSTableComponentRequest extractParamsOrThrow(final RoutingContext rc)
diff --git a/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java b/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java
index e7c97d5..4f045d5 100644
--- a/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java
+++ b/src/main/java/org/apache/cassandra/sidecar/routes/SwaggerOpenApiResource.java
@@ -1,6 +1,24 @@
+/*
+ * 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.cassandra.sidecar.routes;
 
-import java.util.Arrays;
+import java.util.Collections;
 import java.util.HashSet;
 import javax.servlet.ServletConfig;
 import javax.ws.rs.GET;
@@ -31,7 +49,7 @@
     static
     {
         Reader reader = new Reader(new SwaggerConfiguration());
-        OAS = reader.read(new HashSet(Arrays.asList(HealthService.class)));
+        OAS = reader.read(new HashSet<>(Collections.singletonList(HealthService.class)));
     }
 
     @Context
@@ -41,7 +59,7 @@
     Application app;
 
     @GET
-    @Produces({ MediaType.APPLICATION_JSON})
+    @Produces({ MediaType.APPLICATION_JSON })
     @Operation(hidden = true)
     public Response getOpenApi(@Context HttpHeaders headers,
                                @Context UriInfo uriInfo,
diff --git a/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java b/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
index 8cb4a55..227be4e 100644
--- a/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
+++ b/src/main/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilder.java
@@ -1,13 +1,38 @@
+/*
+ * 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.cassandra.sidecar.snapshots;
 
 import java.io.File;
 import java.io.FileNotFoundException;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+import java.nio.file.attribute.BasicFileAttributes;
 import java.util.ArrayList;
 import java.util.Comparator;
 import java.util.List;
+import java.util.function.BiPredicate;
 import java.util.function.Predicate;
 import java.util.stream.Collectors;
 import java.util.stream.IntStream;
+import java.util.stream.Stream;
 
 import org.apache.commons.lang3.StringUtils;
 import org.apache.commons.lang3.tuple.Pair;
@@ -19,9 +44,11 @@
 import io.vertx.core.CompositeFuture;
 import io.vertx.core.Future;
 import io.vertx.core.Promise;
+import io.vertx.core.Vertx;
 import io.vertx.core.file.FileProps;
 import io.vertx.core.file.FileSystem;
 import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesRequest;
 import org.apache.cassandra.sidecar.common.data.StreamSSTableComponentRequest;
 import org.apache.cassandra.sidecar.common.utils.ValidationUtils;
 
@@ -33,21 +60,24 @@
 {
     private static final Logger logger = LoggerFactory.getLogger(SnapshotPathBuilder.class);
     private static final String DATA_SUB_DIR = "/data";
+    public static final int SNAPSHOTS_MAX_DEPTH = 4;
     public static final String SNAPSHOTS_DIR_NAME = "snapshots";
+    protected final Vertx vertx;
     protected final FileSystem fs;
     protected final InstancesConfig instancesConfig;
 
     /**
-     * Creates a new SnapshotPathBuilder for snapshots of an instance with the given {@code fs filesystem} and
+     * Creates a new SnapshotPathBuilder for snapshots of an instance with the given {@code vertx} instance and
      * {@code instancesConfig Cassandra configuration}.
      *
-     * @param fs              the underlying filesystem
-     * @param instancesConfig the configuration for Cassandra
+     * @param vertx                   the vertx instance
+     * @param instancesConfig         the configuration for Cassandra
      */
     @Inject
-    public SnapshotPathBuilder(FileSystem fs, InstancesConfig instancesConfig)
+    public SnapshotPathBuilder(Vertx vertx, InstancesConfig instancesConfig)
     {
-        this.fs = fs;
+        this.vertx = vertx;
+        this.fs = vertx.fileSystem();
         this.instancesConfig = instancesConfig;
     }
 
@@ -72,6 +102,183 @@
     }
 
     /**
+     * Builds the path to the given snapshot directory given the {@code keyspace}, {@code table},
+     * and {@code snapshotName} inside the specified {@code host}. When a table has been dropped and recreated,
+     * the code searches for the latest modified directory for that table.
+     *
+     * @param host    the name of the host
+     * @param request the request to list the snapshot files
+     * @return the absolute path of the snapshot directory
+     */
+    public Future<String> build(String host, ListSnapshotFilesRequest request)
+    {
+        return getDataDirectories(host)
+               .compose(dataDirs -> findKeyspaceDirectory(dataDirs, request.getKeyspace()))
+               .compose(keyspaceDirectory -> findTableDirectory(keyspaceDirectory, request.getTableName()))
+               .compose(tableDirectory -> findSnapshotDirectory(tableDirectory, request.getSnapshotName()));
+    }
+
+    /**
+     * Lists the snapshot directory, if {@code includeSecondaryIndexFiles} is true, the future
+     * will include files inside secondary index directories.
+     *
+     * @param snapshotDirectory          the path to the snapshot directory
+     * @param includeSecondaryIndexFiles whether to include secondary index files
+     * @return a future with a list of files inside the snapshot directory
+     */
+    public Future<List<Pair<String, FileProps>>> listSnapshotDirectory(String snapshotDirectory,
+                                                                       boolean includeSecondaryIndexFiles)
+    {
+        Promise<List<Pair<String, FileProps>>> promise = Promise.promise();
+
+        // List the snapshot directory
+        fs.readDir(snapshotDirectory)
+          .onFailure(promise::fail)
+          .onSuccess(list ->
+          {
+
+              logger.debug("Found {} files in snapshot directory '{}'", list.size(), snapshotDirectory);
+
+              // Prepare futures to get properties for all the files from listing the snapshot directory
+              //noinspection rawtypes
+              List<Future> futures = list.stream()
+                                         .map(fs::props)
+                                         .collect(Collectors.toList());
+
+              CompositeFuture.all(futures)
+                             .onFailure(cause ->
+                             {
+                                 logger.debug("Failed to get FileProps", cause);
+                                 promise.fail(cause);
+                             })
+                             .onSuccess(ar ->
+                             {
+
+                                 // Create a pair of path/fileProps for every regular file
+                                 List<Pair<String, FileProps>> snapshotList =
+                                 IntStream.range(0, list.size())
+                                          .filter(i -> ar.<FileProps>resultAt(i).isRegularFile())
+                                          .mapToObj(i -> Pair.of(list.get(i), ar.<FileProps>resultAt(i)))
+                                          .collect(Collectors.toList());
+
+
+                                 if (!includeSecondaryIndexFiles)
+                                 {
+                                     // We are done if we don't include secondary index files
+                                     promise.complete(snapshotList);
+                                     return;
+                                 }
+
+                                 // Find index directories and prepare futures listing the snapshot directory
+                                 //noinspection rawtypes
+                                 List<Future> idxListFutures =
+                                 IntStream.range(0, list.size())
+                                          .filter(i ->
+                                                  {
+                                                      if (ar.<FileProps>resultAt(i).isDirectory())
+                                                      {
+                                                          Path path = Paths.get(list.get(i));
+                                                          int count = path.getNameCount();
+                                                          return count > 0
+                                                                 && path.getName(count - 1)
+                                                                        .toString()
+                                                                        .startsWith(".");
+                                                      }
+                                                      return false;
+                                                  })
+                                          .mapToObj(i -> listSnapshotDirectory(list.get(i), false))
+                                          .collect(Collectors.toList());
+                                 if (idxListFutures.isEmpty())
+                                 {
+                                     // If there are no secondary index directories we are done
+                                     promise.complete(snapshotList);
+                                     return;
+                                 }
+                                 logger.debug("Found {} index directories in the '{}' snapshot",
+                                              idxListFutures.size(), snapshotDirectory);
+                                 // if we have index directories, list them all
+                                 CompositeFuture.all(idxListFutures)
+                                                .onFailure(promise::fail)
+                                                .onSuccess(idx ->
+                                                {
+                                                    //noinspection unchecked
+                                                    List<Pair<String, FileProps>> idxPropList =
+                                                    idx.list()
+                                                       .stream()
+                                                       .flatMap(l -> ((List<Pair<String, FileProps>>) l).stream())
+                                                       .collect(Collectors.toList());
+
+                                                    // aggregate the results and return the full list
+                                                    snapshotList.addAll(idxPropList);
+                                                    promise.complete(snapshotList);
+                                                });
+                             });
+          });
+        return promise.future();
+    }
+
+    /**
+     * Finds the list of directories that match the given {@code snapshotName} inside the specified {@code host}.
+     *
+     * @param host         the name of the host
+     * @param snapshotName the name of the snapshot
+     * @return a list of absolute paths for the directories that match the given {@code snapshotName} inside the
+     * specified {@code host}
+     */
+    public Future<List<String>> findSnapshotDirectories(String host, String snapshotName)
+    {
+        return getDataDirectories(host)
+               .compose(dataDirs -> findSnapshotDirectoriesRecursively(dataDirs, snapshotName));
+    }
+
+    /**
+     * An optimized implementation to search snapshot directories by {@code snapshotName} recursively in all
+     * the provided {@code dataDirs}.
+     *
+     * @param dataDirs     a list of data directories
+     * @param snapshotName the name of the snapshot
+     * @return a future with the list of snapshot directories that match the {@code snapshotName}
+     */
+    protected Future<List<String>> findSnapshotDirectoriesRecursively(List<String> dataDirs, String snapshotName)
+    {
+        Path snapshotsDirPath = Paths.get(SNAPSHOTS_DIR_NAME);
+        Path snapshotNamePath = Paths.get(snapshotName);
+
+        return vertx.executeBlocking(promise ->
+        {
+
+            // a filter to keep directories ending in "/snapshots/<snapshotName>"
+            BiPredicate<Path, BasicFileAttributes> filter = (path, basicFileAttributes) ->
+            {
+                int nameCount;
+                return basicFileAttributes.isDirectory() &&
+                       (nameCount = path.getNameCount()) >= 2 &&
+                       path.getName(nameCount - 2).endsWith(snapshotsDirPath) &&
+                       path.getName(nameCount - 1).endsWith(snapshotNamePath);
+            };
+
+            // Using optimized Files.find instead of vertx's fs.readDir. Unfortunately
+            // fs.readDir is limited, and it doesn't support recursively searching for files
+            List<String> result = new ArrayList<>();
+            for (String dataDir : dataDirs)
+            {
+                try (Stream<Path> directoryStream = Files.find(Paths.get(dataDir).toAbsolutePath(),
+                                                               SNAPSHOTS_MAX_DEPTH, filter))
+                {
+                    result.addAll(directoryStream.map(Path::toString).collect(Collectors.toList()));
+                }
+                catch (IOException e)
+                {
+                    promise.fail(e);
+                    return;
+                }
+            }
+
+            promise.complete(result);
+        });
+    }
+
+    /**
      * Validates that the component name is either {@code *.db} or a {@code *-TOC.txt}
      * which are the only required components to read SSTables.
      *
@@ -92,8 +299,8 @@
         List<String> dataDirs = instancesConfig.instanceFromHost(host).dataDirs();
         if (dataDirs == null || dataDirs.isEmpty())
         {
-            logger.error("No data directories are available for host '{}'", host);
             String errMsg = String.format("No data directories are available for host '%s'", host);
+            logger.error(errMsg);
             return Future.failedFuture(new FileNotFoundException(errMsg));
         }
         return Future.succeededFuture(dataDirs);
@@ -120,8 +327,12 @@
             Future<String> f = candidates.get(i);
             root = root.recover(v -> f);
         }
-        String errMsg = String.format("Keyspace '%s' does not exist", keyspace);
-        return root.recover(t -> Future.failedFuture(new FileNotFoundException(errMsg)));
+        return root.recover(t ->
+        {
+            String errorMessage = String.format("Keyspace '%s' does not exist", keyspace);
+            logger.debug(errorMessage, t);
+            return Future.failedFuture(new FileNotFoundException(errorMessage));
+        });
     }
 
     /**
@@ -160,6 +371,28 @@
     }
 
     /**
+     * Constructs the path to the snapshot directory using the {@code baseDirectory} and {@code snapshotName}
+     * and returns if it is a valid path to the snapshot directory, or a failure otherwise.
+     *
+     * @param baseDirectory the base directory where we search the snapshot directory
+     * @param snapshotName  the name of the snapshot
+     * @return a future for the path to the snapshot directory if it's valid, or a failed future otherwise
+     */
+    protected Future<String> findSnapshotDirectory(String baseDirectory, String snapshotName)
+    {
+        String snapshotDirectory = StringUtils.removeEnd(baseDirectory, File.separator) +
+                                   File.separator + SNAPSHOTS_DIR_NAME + File.separator + snapshotName;
+
+        return isValidDirectory(snapshotDirectory)
+               .recover(t ->
+               {
+                   String errorMessage = String.format("Snapshot directory '%s' does not exist", snapshotName);
+                   logger.warn("Snapshot directory {} does not exist in {}", snapshotName, snapshotDirectory);
+                   return Future.failedFuture(new FileNotFoundException(errorMessage));
+               });
+    }
+
+    /**
      * Constructs the path to the component using the {@code baseDirectory}, {@code snapshotName}, and
      * {@code componentName} and returns if it is a valid path to the component, or a failure otherwise.
      *
diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java b/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
index 131320e..c327d34 100644
--- a/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
+++ b/src/main/java/org/apache/cassandra/sidecar/utils/FileStreamer.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.utils;
 
 import java.time.Duration;
diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/InstanceMetadataFetcher.java b/src/main/java/org/apache/cassandra/sidecar/utils/InstanceMetadataFetcher.java
index f74d08e..3eecbc9 100644
--- a/src/main/java/org/apache/cassandra/sidecar/utils/InstanceMetadataFetcher.java
+++ b/src/main/java/org/apache/cassandra/sidecar/utils/InstanceMetadataFetcher.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.utils;
 
 import com.google.inject.Inject;
diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/RequestUtils.java b/src/main/java/org/apache/cassandra/sidecar/utils/RequestUtils.java
index 22ffdcc..34cc31f 100644
--- a/src/main/java/org/apache/cassandra/sidecar/utils/RequestUtils.java
+++ b/src/main/java/org/apache/cassandra/sidecar/utils/RequestUtils.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.utils;
 
 /**
diff --git a/src/main/java/org/apache/cassandra/sidecar/utils/YAMLKeyConstants.java b/src/main/java/org/apache/cassandra/sidecar/utils/YAMLKeyConstants.java
index 0b73866..cce239a 100644
--- a/src/main/java/org/apache/cassandra/sidecar/utils/YAMLKeyConstants.java
+++ b/src/main/java/org/apache/cassandra/sidecar/utils/YAMLKeyConstants.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.utils;
 
 /**
diff --git a/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java b/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
index a6801a8..0938a9d 100644
--- a/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/AbstractHealthServiceTest.java
@@ -21,12 +21,10 @@
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
 
-import org.junit.Assert;
 import org.junit.jupiter.api.AfterEach;
 import org.junit.jupiter.api.BeforeEach;
 import org.junit.jupiter.api.DisplayName;
 import org.junit.jupiter.api.Test;
-
 import org.slf4j.Logger;
 import org.slf4j.LoggerFactory;
 
@@ -43,6 +41,10 @@
 import io.vertx.junit5.VertxTestContext;
 import org.apache.cassandra.sidecar.routes.HealthService;
 
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static io.netty.handler.codec.http.HttpResponseStatus.SERVICE_UNAVAILABLE;
+import static org.assertj.core.api.Assertions.assertThat;
+
 /**
  * Provides basic tests shared between SSL and normal http health services
  */
@@ -74,7 +76,7 @@
         config = injector.getInstance(Configuration.class);
 
         VertxTestContext context = new VertxTestContext();
-        server.listen(config.getPort(), context.completing());
+        server.listen(config.getPort(), context.succeedingThenComplete());
 
         context.awaitCompletion(5, TimeUnit.SECONDS);
     }
@@ -102,8 +104,8 @@
               .ssl(isSslEnabled())
               .send(testContext.succeeding(response -> testContext.verify(() ->
               {
-                  Assert.assertEquals(200, response.statusCode());
-                  Assert.assertEquals("{\"status\":\"OK\"}", response.body());
+                  assertThat(response.statusCode()).isEqualTo(OK.code());
+                  assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}");
                   testContext.completeNow();
               })));
     }
@@ -135,8 +137,8 @@
               .ssl(isSslEnabled())
               .send(testContext.succeeding(response -> testContext.verify(() ->
               {
-                  Assert.assertEquals(200, response.statusCode());
-                  Assert.assertEquals("{\"status\":\"OK\"}", response.body());
+                  assertThat(response.statusCode()).isEqualTo(OK.code());
+                  assertThat(response.body()).isEqualTo("{\"status\":\"OK\"}");
                   testContext.completeNow();
               })));
     }
@@ -152,8 +154,25 @@
               .ssl(isSslEnabled())
               .send(testContext.succeeding(response -> testContext.verify(() ->
               {
-                  Assert.assertEquals(503, response.statusCode());
-                  Assert.assertEquals("{\"status\":\"NOT_OK\"}", response.body());
+                  assertThat(response.statusCode()).isEqualTo(SERVICE_UNAVAILABLE.code());
+                  assertThat(response.body()).isEqualTo("{\"status\":\"NOT_OK\"}");
+                  testContext.completeNow();
+              })));
+    }
+
+    @DisplayName("Should return HTTP 503 Failure when instance is down with query param")
+    @Test
+    public void testHealthCheckReturns503FailureWithQueryParam(VertxTestContext testContext)
+    {
+        WebClient client = getClient();
+
+        client.get(config.getPort(), "localhost", "/api/v1/cassandra/__health?instanceId=2")
+              .as(BodyCodec.string())
+              .ssl(isSslEnabled())
+              .send(testContext.succeeding(response -> testContext.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(SERVICE_UNAVAILABLE.code());
+                  assertThat(response.body()).isEqualTo("{\"status\":\"NOT_OK\"}");
                   testContext.completeNow();
               })));
     }
diff --git a/src/test/java/org/apache/cassandra/sidecar/ConfigurationTest.java b/src/test/java/org/apache/cassandra/sidecar/ConfigurationTest.java
index 86d3632..8e7db7d 100644
--- a/src/test/java/org/apache/cassandra/sidecar/ConfigurationTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/ConfigurationTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar;
 
 import java.io.FileInputStream;
diff --git a/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java b/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java
index b31b29a..b660fad 100644
--- a/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/LoggerHandlerInjectionTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar;
 
 import java.util.concurrent.CountDownLatch;
diff --git a/src/test/java/org/apache/cassandra/sidecar/RangeTest.java b/src/test/java/org/apache/cassandra/sidecar/RangeTest.java
index 82c5d5e..e98392d 100644
--- a/src/test/java/org/apache/cassandra/sidecar/RangeTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/RangeTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar;
 
 import org.junit.jupiter.api.Test;
diff --git a/src/test/java/org/apache/cassandra/sidecar/TestModule.java b/src/test/java/org/apache/cassandra/sidecar/TestModule.java
index 708b684..2ae22ad 100644
--- a/src/test/java/org/apache/cassandra/sidecar/TestModule.java
+++ b/src/test/java/org/apache/cassandra/sidecar/TestModule.java
@@ -119,7 +119,8 @@
 
     /**
      * The Mock factory is used for testing purposes, enabling us to test all failures and possible results
-     * @return
+     *
+     * @return the {@link CassandraVersionProvider}
      */
     @Provides
     @Singleton
diff --git a/src/test/java/org/apache/cassandra/sidecar/ThrottleTest.java b/src/test/java/org/apache/cassandra/sidecar/ThrottleTest.java
index 65f39b9..1f3cf79 100644
--- a/src/test/java/org/apache/cassandra/sidecar/ThrottleTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/ThrottleTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar;
 
 import java.util.concurrent.CompletableFuture;
@@ -47,7 +65,7 @@
         config = injector.getInstance(Configuration.class);
 
         VertxTestContext context = new VertxTestContext();
-        server.listen(config.getPort(), context.completing());
+        server.listen(config.getPort(), context.succeedingThenComplete());
 
         context.awaitCompletion(5, SECONDS);
     }
diff --git a/src/test/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandlerTest.java b/src/test/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandlerTest.java
new file mode 100644
index 0000000..7c959c9
--- /dev/null
+++ b/src/test/java/org/apache/cassandra/sidecar/routes/ListSnapshotFilesHandlerTest.java
@@ -0,0 +1,163 @@
+/*
+ * 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.cassandra.sidecar.routes;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+
+import com.google.inject.AbstractModule;
+import com.google.inject.Guice;
+import com.google.inject.Injector;
+import com.google.inject.util.Modules;
+import io.vertx.core.Vertx;
+import io.vertx.core.http.HttpServer;
+import io.vertx.ext.web.client.WebClient;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+import org.apache.cassandra.sidecar.Configuration;
+import org.apache.cassandra.sidecar.MainModule;
+import org.apache.cassandra.sidecar.TestModule;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesResponse;
+import org.apache.cassandra.sidecar.snapshots.SnapshotUtils;
+
+import static io.netty.handler.codec.http.HttpResponseStatus.NOT_FOUND;
+import static io.netty.handler.codec.http.HttpResponseStatus.OK;
+import static org.apache.cassandra.sidecar.snapshots.SnapshotUtils.mockInstancesConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+
+
+/**
+ * Tests for the {@link ListSnapshotFilesHandler}
+ */
+@ExtendWith(VertxExtension.class)
+public class ListSnapshotFilesHandlerTest
+{
+    private static final Logger logger = LoggerFactory.getLogger(ListSnapshotFilesHandlerTest.class);
+    private Vertx vertx;
+    private HttpServer server;
+    private Configuration config;
+    @TempDir
+    File temporaryFolder;
+
+    @BeforeEach
+    public void setup() throws InterruptedException, IOException
+    {
+        Injector injector = Guice.createInjector(Modules.override(new MainModule())
+                                                        .with(Modules.override(new TestModule())
+                                                                     .with(new ListSnapshotTestModule())));
+        server = injector.getInstance(HttpServer.class);
+        vertx = injector.getInstance(Vertx.class);
+        config = injector.getInstance(Configuration.class);
+
+        VertxTestContext context = new VertxTestContext();
+        server.listen(config.getPort(), config.getHost(), context.succeedingThenComplete());
+
+        context.awaitCompletion(5, TimeUnit.SECONDS);
+        SnapshotUtils.initializeTmpDirectory(temporaryFolder);
+    }
+
+    @AfterEach
+    void tearDown() throws InterruptedException
+    {
+        final CountDownLatch closeLatch = new CountDownLatch(1);
+        server.close(res -> closeLatch.countDown());
+        vertx.close();
+        if (closeLatch.await(60, TimeUnit.SECONDS))
+            logger.info("Close event received before timeout.");
+        else
+            logger.error("Close event timed out.");
+    }
+
+    @Test
+    public void testRouteSucceedsWithKeyspaceAndTableName(VertxTestContext context)
+    {
+        WebClient client = WebClient.create(vertx);
+        String testRoute = "/api/v1/keyspace/keyspace1/table/table1-1234/snapshots/snapshot1";
+        ListSnapshotFilesResponse.FileInfo fileInfoExpected =
+        new ListSnapshotFilesResponse.FileInfo(11,
+                                               "localhost",
+                                               9043,
+                                               0,
+                                               "snapshot1",
+                                               "keyspace1",
+                                               "table1-1234",
+                                               "1.db");
+        ListSnapshotFilesResponse.FileInfo fileInfoNotExpected =
+        new ListSnapshotFilesResponse.FileInfo(11,
+                                               "localhost",
+                                               9043,
+                                               0,
+                                               "snapshot1",
+                                               "keyspace1",
+                                               "table1-1234",
+                                               "2.db");
+
+        client.get(config.getPort(), "localhost", testRoute)
+              .send(context.succeeding(response -> context.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(OK.code());
+                  ListSnapshotFilesResponse resp = response.bodyAsJson(ListSnapshotFilesResponse.class);
+                  assertThat(resp.getSnapshotFilesInfo().size()).isEqualTo(1);
+                  assertThat(resp.getSnapshotFilesInfo()).contains(fileInfoExpected);
+                  assertThat(resp.getSnapshotFilesInfo()).doesNotContain(fileInfoNotExpected);
+                  context.completeNow();
+              })));
+    }
+
+    @Test
+    public void testRouteInvalidSnapshot(VertxTestContext context)
+    {
+        WebClient client = WebClient.create(vertx);
+        String testRoute = "/api/v1/keyspace/keyspace1/table/table1-1234/snapshots/snapshotInvalid";
+        client.get(config.getPort(), "localhost", testRoute)
+              .send(context.succeeding(response -> context.verify(() ->
+              {
+                  assertThat(response.statusCode()).isEqualTo(NOT_FOUND.code());
+                  assertThat(response.statusMessage()).isEqualTo(NOT_FOUND.reasonPhrase());
+                  context.completeNow();
+              })));
+    }
+
+    class ListSnapshotTestModule extends AbstractModule
+    {
+        @Override
+        protected void configure()
+        {
+            try
+            {
+                bind(InstancesConfig.class).toInstance(mockInstancesConfig(temporaryFolder.getCanonicalPath()));
+            }
+            catch (IOException e)
+            {
+                throw new RuntimeException(e);
+            }
+        }
+    }
+}
diff --git a/src/test/java/org/apache/cassandra/sidecar/StreamSSTableComponentTest.java b/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
similarity index 92%
rename from src/test/java/org/apache/cassandra/sidecar/StreamSSTableComponentTest.java
rename to src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
index 889baf9..91916ed 100644
--- a/src/test/java/org/apache/cassandra/sidecar/StreamSSTableComponentTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/routes/StreamSSTableComponentHandlerTest.java
@@ -1,4 +1,22 @@
-package org.apache.cassandra.sidecar;
+/*
+ * 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.cassandra.sidecar.routes;
 
 import java.util.concurrent.CountDownLatch;
 import java.util.concurrent.TimeUnit;
@@ -19,6 +37,9 @@
 import io.vertx.ext.web.codec.BodyCodec;
 import io.vertx.junit5.VertxExtension;
 import io.vertx.junit5.VertxTestContext;
+import org.apache.cassandra.sidecar.Configuration;
+import org.apache.cassandra.sidecar.MainModule;
+import org.apache.cassandra.sidecar.TestModule;
 
 import static io.netty.handler.codec.http.HttpResponseStatus.BAD_REQUEST;
 import static io.netty.handler.codec.http.HttpResponseStatus.FORBIDDEN;
@@ -32,9 +53,9 @@
  * Test for StreamSSTableComponent
  */
 @ExtendWith(VertxExtension.class)
-public class StreamSSTableComponentTest
+public class StreamSSTableComponentHandlerTest
 {
-    private static final Logger logger = LoggerFactory.getLogger(StreamSSTableComponentTest.class);
+    private static final Logger logger = LoggerFactory.getLogger(StreamSSTableComponentHandlerTest.class);
     private Vertx vertx;
     private HttpServer server;
     private Configuration config;
@@ -319,7 +340,7 @@
         String testRoute = "/keyspace/TestKeyspace/table/TestTable-54ea95ce-bba2-4e0a-a9be-e428e5d7160b/" +
                            "snapshots/TestSnapshot/component/" +
                            "TestKeyspace-TestTable-54ea95ce-bba2-4e0a-a9be-e428e5d7160b-Data.db";
-        client.get(config.getPort(), "localhost", "/api/v1/instance/2" + testRoute)
+        client.get(config.getPort(), "localhost", "/api/v1" + testRoute + "?instanceId=2")
               .as(BodyCodec.buffer())
               .send(context.succeeding(response -> context.verify(() ->
               {
diff --git a/src/test/java/org/apache/cassandra/sidecar/snapshots/AbstractSnapshotPathBuilderTest.java b/src/test/java/org/apache/cassandra/sidecar/snapshots/AbstractSnapshotPathBuilderTest.java
index 0aa2e3e..763b906 100644
--- a/src/test/java/org/apache/cassandra/sidecar/snapshots/AbstractSnapshotPathBuilderTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/snapshots/AbstractSnapshotPathBuilderTest.java
@@ -1,9 +1,28 @@
+/*
+ * 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.cassandra.sidecar.snapshots;
 
 import java.io.File;
 import java.io.FileNotFoundException;
 import java.io.IOException;
 import java.util.Arrays;
+import java.util.Collections;
 import java.util.concurrent.TimeUnit;
 
 import org.junit.jupiter.api.AfterEach;
@@ -21,6 +40,7 @@
 import io.vertx.junit5.VertxTestContext;
 import org.apache.cassandra.sidecar.cluster.InstancesConfig;
 import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.common.data.ListSnapshotFilesRequest;
 import org.apache.cassandra.sidecar.common.data.StreamSSTableComponentRequest;
 
 import static org.assertj.core.api.Assertions.assertThat;
@@ -55,203 +75,67 @@
 
         when(mockInstancesConfig.instanceFromHost("invalidDataDirInstance")).thenReturn(mockInvalidDataDirInstanceMeta);
         String invalidDirPath = dataDir0.getParentFile().getAbsolutePath() + "/invalid-data-dir";
-        when(mockInvalidDataDirInstanceMeta.dataDirs()).thenReturn(Arrays.asList(invalidDirPath));
+        when(mockInvalidDataDirInstanceMeta.dataDirs()).thenReturn(Collections.singletonList(invalidDirPath));
 
         when(mockInstancesConfig.instanceFromHost("emptyDataDirInstance")).thenReturn(mockEmptyDataDirInstanceMeta);
-        when(mockEmptyDataDirInstanceMeta.dataDirs()).thenReturn(Arrays.asList());
+        when(mockEmptyDataDirInstanceMeta.dataDirs()).thenReturn(Collections.emptyList());
 
         // Create some files and directories
-        assertThat(new File(dataDir0, "not_a_keyspace_dir").createNewFile());
-        assertThat(new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/not_a_file.db").mkdirs());
-        assertThat(new File(dataDir0, "ks1/not_a_table_dir").createNewFile());
-        assertThat(new File(dataDir0, "ks1/table1/snapshots/not_a_snapshot_dir").createNewFile());
-        assertThat(new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd").mkdirs());
+        new File(dataDir0, "not_a_keyspace_dir").createNewFile();
+        new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/not_a_file.db").mkdirs();
+        new File(dataDir0, "ks1/not_a_table_dir").createNewFile();
+        new File(dataDir0, "ks1/table1/snapshots/not_a_snapshot_dir").createNewFile();
+        new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd").mkdirs();
 
-        assertThat(new File(dataDir1, "ks3/table3/snapshots/snapshot1").mkdirs());
+        new File(dataDir1, "ks3/table3/snapshots/snapshot1").mkdirs();
 
         // this is a different table with the same "table4" prefix
-        assertThat(new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
-                                      "/snapshots/this_is_a_valid_snapshot_name_i_❤_u").mkdirs());
+        new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
+                           "/snapshots/this_is_a_valid_snapshot_name_i_❤_u").mkdirs();
 
         // table && table-<TABLE_UUID>
-        assertThat(new File(dataDir0, "ks1/a_table/snapshots/a_snapshot/").mkdirs());
-        assertThat(new File(dataDir0, "ks1/a_table-a72c8740a57611ec935db766a70c44a1/snapshots/a_snapshot/").mkdirs());
+        new File(dataDir0, "ks1/a_table/snapshots/a_snapshot/").mkdirs();
+        new File(dataDir0, "ks1/a_table-a72c8740a57611ec935db766a70c44a1/snapshots/a_snapshot/").mkdirs();
 
         // create some files inside snapshot backup.2022-03-17-04-PDT
-        assertThat(new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/data.db").createNewFile());
-        assertThat(new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/index.db").createNewFile());
-        assertThat(new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/nb-203-big-TOC.txt")
-                   .createNewFile());
+        new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/data.db").createNewFile();
+        new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/index.db").createNewFile();
+        new File(dataDir0, "ks1/table1/snapshots/backup.2022-03-17-04-PDT/nb-203-big-TOC.txt").createNewFile();
 
         // create some files inside snapshot ea823202-a62c-4603-bb6a-4e15d79091cd
-        assertThat(new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/data.db")
-                   .createNewFile());
-        assertThat(new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/index.db")
-                   .createNewFile());
-        assertThat(
+        new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/data.db")
+        .createNewFile();
+        new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/index.db")
+        .createNewFile();
         new File(dataDir0, "data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/nb-203-big-TOC.txt")
-        .createNewFile());
+        .createNewFile();
 
         // create some files inside snapshot snapshot1 in dataDir1
-        assertThat(new File(dataDir1, "ks3/table3/snapshots/snapshot1/data.db").createNewFile());
-        assertThat(new File(dataDir1, "ks3/table3/snapshots/snapshot1/index.db").createNewFile());
-        assertThat(new File(dataDir1, "ks3/table3/snapshots/snapshot1/nb-203-big-TOC.txt").createNewFile());
+        new File(dataDir1, "ks3/table3/snapshots/snapshot1/data.db").createNewFile();
+        new File(dataDir1, "ks3/table3/snapshots/snapshot1/index.db").createNewFile();
+        new File(dataDir1, "ks3/table3/snapshots/snapshot1/nb-203-big-TOC.txt").createNewFile();
 
-        assertThat(new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
-                                      "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile());
-        assertThat(new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
-                                      "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile());
-        assertThat(new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
-                                      "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt")
-                   .createNewFile());
+        new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
+                           "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile();
+        new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
+                           "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile();
+        new File(dataDir1, "data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1" +
+                           "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt").createNewFile();
 
         vertx = Vertx.vertx();
         instance = initialize(vertx, mockInstancesConfig);
     }
 
+    @SuppressWarnings("ResultOfMethodCallIgnored")
     @AfterEach
     void clear()
     {
-        assertThat(dataDir0.delete());
-        assertThat(dataDir1.delete());
+        dataDir0.delete();
+        dataDir1.delete();
     }
 
     abstract SnapshotPathBuilder initialize(Vertx vertx, InstancesConfig instancesConfig);
 
-    @Test
-    void failsWhenKeyspaceIsNull()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest(null, "table",
-                                                                                  "snapshot", "component")))
-        .isInstanceOf(NullPointerException.class)
-        .hasMessageContaining("keyspace must not be null");
-    }
-
-    @Test
-    void failsWhenKeyspaceContainsInvalidCharacters()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("i_❤_u", "table",
-                                                                                  "snapshot", "component")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Bad Request")
-        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Invalid characters in keyspace: i_❤_u", from(t -> ((HttpException) t).getPayload()));
-    }
-
-    @Test
-    void failsWhenKeyspaceContainsPathTraversalAttack()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("../../../etc/passwd",
-                                                                                  "table",
-                                                                                  "snapshot",
-                                                                                  "component")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Bad Request")
-        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Invalid characters in keyspace: ../../../etc/passwd", from(t -> ((HttpException) t)
-                                                                                  .getPayload()));
-    }
-
-    @ParameterizedTest
-    @ValueSource(strings = { "system_schema", "system_traces", "system_distributed", "system", "system_auth",
-                             "system_views", "system_virtual_schema" })
-    void failsWhenKeyspaceIsForbidden(String forbiddenKeyspace)
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest(forbiddenKeyspace,
-                                                                                  "table",
-                                                                                  "snapshot",
-                                                                                  "component")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Forbidden")
-        .returns(HttpResponseStatus.FORBIDDEN.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Forbidden keyspace: " + forbiddenKeyspace, from(t -> ((HttpException) t).getPayload()));
-    }
-
-    @Test
-    void failsWhenTableNameIsNull()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  null,
-                                                                                  "snapshot",
-                                                                                  "component")))
-        .isInstanceOf(NullPointerException.class)
-        .hasMessageContaining("tableName must not be null");
-    }
-
-    @Test
-    void failsWhenTableNameContainsInvalidCharacters()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  "i_❤_u",
-                                                                                  "snapshot",
-                                                                                  "component")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Bad Request")
-        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Invalid characters in table name: i_❤_u", from(t -> ((HttpException) t).getPayload()));
-    }
-
-    @Test
-    void failsWhenTableNameContainsPathTraversalAttack()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  "../../../etc/passwd",
-                                                                                  "snapshot",
-                                                                                  "component")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Bad Request")
-        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Invalid characters in table name: ../../../etc/passwd", from(t -> ((HttpException) t)
-                                                                                    .getPayload()));
-    }
-
-    @Test
-    void failsWhenSnapshotNameIsNull()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  "table",
-                                                                                  null,
-                                                                                  "component.db")))
-        .isInstanceOf(NullPointerException.class)
-        .hasMessageContaining("snapshotName must not be null");
-    }
-
-    @ParameterizedTest
-    @ValueSource(strings = { "slash/is-not-allowed", "null-char\0-is-not-allowed", "../../../etc/passwd" })
-    void failsWhenSnapshotNameContainsInvalidCharacters(String invalidFileName)
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  "table",
-                                                                                  invalidFileName,
-                                                                                  "component.db")))
-        .isInstanceOf(HttpException.class)
-        .hasMessageContaining("Bad Request")
-        .returns(HttpResponseStatus.BAD_REQUEST.code(), from(t -> ((HttpException) t).getStatusCode()))
-        .returns("Invalid characters in snapshot name: " + invalidFileName, from(t -> ((HttpException) t)
-                                                                                      .getPayload()));
-    }
-
-    @Test
-    void failsWhenComponentNameIsNull()
-    {
-        assertThatThrownBy(() -> instance.build("localhost",
-                                                new StreamSSTableComponentRequest("ks",
-                                                                                  "table",
-                                                                                  "snapshot",
-                                                                                  null)))
-        .isInstanceOf(NullPointerException.class)
-        .hasMessageContaining("componentName must not be null");
-    }
-
     @ParameterizedTest
     @ValueSource(strings = { "i_❤_u.db", "this-is-not-allowed.jar", "cql-is-not-allowed-here.cql",
                              "json-is-not-allowed-here.json", "crc32-is-not-allowed-here.crc32",
@@ -271,7 +155,7 @@
     }
 
     @Test
-    void failsWhenDataDirsAreEmpty() throws InterruptedException
+    void failsWhenDataDirsAreEmpty()
     {
         failsWithFileNotFoundException(instance.build("emptyDataDirInstance",
                                                       new StreamSSTableComponentRequest("ks",
@@ -279,10 +163,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "No data directories are available for host 'emptyDataDirInstance'");
+        failsWithFileNotFoundException(instance.build("emptyDataDirInstance",
+                                                      new ListSnapshotFilesRequest("ks",
+                                                                                   "table",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "No data directories are available for host 'emptyDataDirInstance'");
     }
 
     @Test
-    void failsWhenInvalidDataDirectory() throws InterruptedException
+    void failsWhenInvalidDataDirectory()
     {
         failsWithFileNotFoundException(instance.build("invalidDataDirInstance",
                                                       new StreamSSTableComponentRequest("ks",
@@ -290,10 +180,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Keyspace 'ks' does not exist");
+        failsWithFileNotFoundException(instance.build("invalidDataDirInstance",
+                                                      new ListSnapshotFilesRequest("ks",
+                                                                                   "table",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Keyspace 'ks' does not exist");
     }
 
     @Test
-    void failsWhenKeyspaceDirectoryDoesNotExist() throws InterruptedException
+    void failsWhenKeyspaceDirectoryDoesNotExist()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("non_existent",
@@ -301,10 +197,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Keyspace 'non_existent' does not exist");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("non_existent",
+                                                                                   "table",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Keyspace 'non_existent' does not exist");
     }
 
     @Test
-    void failsWhenKeyspaceIsNotADirectory() throws InterruptedException
+    void failsWhenKeyspaceIsNotADirectory()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("not_a_keyspace_dir",
@@ -312,10 +214,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Keyspace 'not_a_keyspace_dir' does not exist");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("not_a_keyspace_dir",
+                                                                                   "table",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Keyspace 'not_a_keyspace_dir' does not exist");
     }
 
     @Test
-    void failsWhenTableDoesNotExist() throws InterruptedException
+    void failsWhenTableDoesNotExist()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("ks1",
@@ -323,10 +231,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Table 'non_existent' does not exist");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("ks1",
+                                                                                   "non_existent",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Table 'non_existent' does not exist");
     }
 
     @Test
-    void failsWhenTableDoesNotExistWithSimilarPrefix() throws InterruptedException
+    void failsWhenTableDoesNotExistWithSimilarPrefix()
     {
         // In this scenario, we have other tables with the "table" prefix (i.e table4)
         failsWithFileNotFoundException(instance.build("localhost",
@@ -335,10 +249,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Table 'table' does not exist");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("ks1",
+                                                                                   "table",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Table 'table' does not exist");
     }
 
     @Test
-    void failsWhenTableNameIsNotADirectory() throws InterruptedException
+    void failsWhenTableNameIsNotADirectory()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("ks1",
@@ -346,10 +266,16 @@
                                                                                         "snapshot",
                                                                                         "component.db")),
                                        "Table 'not_a_table_dir' does not exist");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("ks1",
+                                                                                   "not_a_table_dir",
+                                                                                   "snapshot",
+                                                                                   false)),
+                                       "Table 'not_a_table_dir' does not exist");
     }
 
     @Test
-    void failsWhenSnapshotDirectoryDoesNotExist() throws InterruptedException
+    void failsWhenSnapshotDirectoryDoesNotExist()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("ks1",
@@ -357,10 +283,16 @@
                                                                                         "non_existent",
                                                                                         "component.db")),
                                        "Component 'component.db' does not exist for snapshot 'non_existent'");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("ks1",
+                                                                                   "table1",
+                                                                                   "non_existent",
+                                                                                   false)),
+                                       "Snapshot directory 'non_existent' does not exist");
     }
 
     @Test
-    void failsWhenSnapshotIsNotADirectory() throws InterruptedException
+    void failsWhenSnapshotIsNotADirectory()
     {
         failsWithFileNotFoundException(instance.build("localhost",
                                                       new StreamSSTableComponentRequest("ks1",
@@ -368,10 +300,16 @@
                                                                                         "not_a_snapshot_dir",
                                                                                         "component.db")),
                                        "Component 'component.db' does not exist for snapshot 'not_a_snapshot_dir'");
+        failsWithFileNotFoundException(instance.build("localhost",
+                                                      new ListSnapshotFilesRequest("ks1",
+                                                                                   "table1",
+                                                                                   "not_a_snapshot_dir",
+                                                                                   false)),
+                                       "Snapshot directory 'not_a_snapshot_dir' does not exist");
     }
 
     @Test
-    void failsWhenComponentFileDoesNotExist() throws InterruptedException
+    void failsWhenComponentFileDoesNotExist()
     {
         String errMsg = "Component 'does-not-exist-TOC.txt' does not exist for snapshot 'backup.2022-03-17-04-PDT'";
         failsWithFileNotFoundException(instance.build("localhost",
@@ -383,7 +321,7 @@
     }
 
     @Test
-    void failsWhenComponentIsNotAFile() throws InterruptedException
+    void failsWhenComponentIsNotAFile()
     {
         String errMsg = "Component 'not_a_file.db' does not exist for snapshot 'backup.2022-03-17-04-PDT'";
         failsWithFileNotFoundException(instance.build("localhost",
@@ -395,118 +333,155 @@
     }
 
     @Test
-    void succeedsWhenComponentExists() throws Exception
+    void succeedsWhenComponentExists()
     {
         String expectedPath;
         expectedPath = dataDir0.getAbsolutePath() + "/ks1/table1/snapshots/backup.2022-03-17-04-PDT/data.db";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks1",
-                                                                                     "table1",
-                                                                                     "backup.2022-03-17-04-PDT",
-                                                                                     "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks1",
+                                                                                "table1",
+                                                                                "backup.2022-03-17-04-PDT",
+                                                                                "data.db")),
+                               expectedPath);
         expectedPath = dataDir0.getAbsolutePath() + "/ks1/table1/snapshots/backup.2022-03-17-04-PDT/index.db";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks1",
-                                                                                     "table1",
-                                                                                     "backup.2022-03-17-04-PDT",
-                                                                                     "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks1",
+                                                                                "table1",
+                                                                                "backup.2022-03-17-04-PDT",
+                                                                                "index.db")),
+                               expectedPath);
         expectedPath = dataDir0.getAbsolutePath() + "/ks1/table1/snapshots/backup.2022-03-17-04-PDT/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks1",
-                                                                                     "table1",
-                                                                                     "backup.2022-03-17-04-PDT",
-                                                                                     "nb-203-big-TOC.txt")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks1",
+                                                                                "table1",
+                                                                                "backup.2022-03-17-04-PDT",
+                                                                                "nb-203-big-TOC.txt")),
+                               expectedPath);
         expectedPath = dataDir0.getAbsolutePath()
                        + "/data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/data.db";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks2",
-                                                                             "table2",
-                                                                             "ea823202-a62c-4603-bb6a-4e15d79091cd",
-                                                                             "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks2",
+                                                                        "table2",
+                                                                        "ea823202-a62c-4603-bb6a-4e15d79091cd",
+                                                                        "data.db")),
+                               expectedPath);
         expectedPath = dataDir0.getAbsolutePath()
                        + "/data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/index.db";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks2",
-                                                                             "table2",
-                                                                             "ea823202-a62c-4603-bb6a-4e15d79091cd",
-                                                                             "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks2",
+                                                                        "table2",
+                                                                        "ea823202-a62c-4603-bb6a-4e15d79091cd",
+                                                                        "index.db")),
+                               expectedPath);
         expectedPath = dataDir0.getAbsolutePath()
                        + "/data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks2",
-                                                                             "table2",
-                                                                             "ea823202-a62c-4603-bb6a-4e15d79091cd",
-                                                                             "nb-203-big-TOC.txt")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks2",
+                                                                        "table2",
+                                                                        "ea823202-a62c-4603-bb6a-4e15d79091cd",
+                                                                        "nb-203-big-TOC.txt")),
+                               expectedPath);
         expectedPath = dataDir1.getAbsolutePath() + "/ks3/table3/snapshots/snapshot1/data.db";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks3",
-                                                                                     "table3",
-                                                                                     "snapshot1",
-                                                                                     "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks3",
+                                                                                "table3",
+                                                                                "snapshot1",
+                                                                                "data.db")),
+                               expectedPath);
         expectedPath = dataDir1.getAbsolutePath() + "/ks3/table3/snapshots/snapshot1/index.db";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks3",
-                                                                                     "table3",
-                                                                                     "snapshot1",
-                                                                                     "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks3",
+                                                                                "table3",
+                                                                                "snapshot1",
+                                                                                "index.db")),
+                               expectedPath);
         expectedPath = dataDir1.getAbsolutePath() + "/ks3/table3/snapshots/snapshot1/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(instance.build("localhost",
-                                                   new StreamSSTableComponentRequest("ks3",
-                                                                                     "table3",
-                                                                                     "snapshot1",
-                                                                                     "nb-203-big-TOC.txt")),
-                                    expectedPath);
-
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new StreamSSTableComponentRequest("ks3",
+                                                                                "table3",
+                                                                                "snapshot1",
+                                                                                "nb-203-big-TOC.txt")),
+                               expectedPath);
 
 
         // table table4 shares the prefix with table table4abc
         expectedPath = dataDir1.getAbsolutePath()
                        + "/data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1"
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4abc",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4abc",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "data.db")),
+                               expectedPath);
         expectedPath = dataDir1.getAbsolutePath()
                        + "/data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1"
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4abc",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4abc",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "index.db")),
+                               expectedPath);
         expectedPath = dataDir1.getAbsolutePath()
                        + "/data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1"
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(instance
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4abc",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "nb-203-big-TOC.txt")),
-                                    expectedPath);
-
-
+        succeedsWhenPathExists(instance
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4abc",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "nb-203-big-TOC.txt")),
+                               expectedPath);
     }
 
     @Test
-    void testTableWithUUIDPicked() throws IOException, InterruptedException
+    void succeedsWhenSnapshotExists()
+    {
+        String expectedPath = dataDir0.getAbsolutePath() + "/ks1/table1/snapshots/backup.2022-03-17-04-PDT";
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new ListSnapshotFilesRequest("ks1",
+                                                                           "table1",
+                                                                           "backup.2022-03-17-04-PDT",
+                                                                           false)),
+                               expectedPath);
+
+        expectedPath = dataDir0.getAbsolutePath() + "/data/ks2/table2/snapshots/ea823202-a62c-4603-bb6a-4e15d79091cd";
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new ListSnapshotFilesRequest("ks2",
+                                                                           "table2",
+                                                                           "ea823202-a62c-4603-bb6a-4e15d79091cd",
+                                                                           false)),
+                               expectedPath);
+
+        expectedPath = dataDir1.getAbsolutePath() + "/ks3/table3/snapshots/snapshot1";
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new ListSnapshotFilesRequest("ks3",
+                                                                           "table3",
+                                                                           "snapshot1",
+                                                                           false)),
+                               expectedPath);
+
+        // table table4 shares the prefix with table table4abc
+        expectedPath = dataDir1.getAbsolutePath()
+                       + "/data/ks4/table4abc-a72c8740a57611ec935db766a70c44a1"
+                       + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u";
+        succeedsWhenPathExists(instance.build("localhost",
+                                              new ListSnapshotFilesRequest("ks4",
+                                                                           "table4abc",
+                                                                           "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                           false)),
+                               expectedPath);
+    }
+
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    @Test
+    void testTableWithUUIDPicked() throws IOException
     {
         TemporaryFolder tempFolder = new TemporaryFolder();
         tempFolder.create();
@@ -516,57 +491,87 @@
         InstanceMetadata mockInstanceMeta = mock(InstanceMetadata.class);
 
         when(mockInstancesConfig.instanceFromHost("localhost")).thenReturn(mockInstanceMeta);
-        when(mockInstanceMeta.dataDirs()).thenReturn(Arrays.asList(dataDir.getAbsolutePath()));
+        when(mockInstanceMeta.dataDirs()).thenReturn(Collections.singletonList(dataDir.getAbsolutePath()));
 
         File atable = new File(dataDir, "data/ks1/a_table");
-        assertThat(atable.mkdirs());
+        atable.mkdirs();
         File atableSnapshot = new File(atable, "snapshots/a_snapshot");
-        assertThat(atableSnapshot.mkdirs());
-        assertThat(new File(atable, "snapshots/a_snapshot/data.db").createNewFile());
-        assertThat(new File(atable, "snapshots/a_snapshot/index.db").createNewFile());
-        assertThat(new File(atable, "snapshots/a_snapshot/nb-203-big-TOC.txt").createNewFile());
+        atableSnapshot.mkdirs();
+        new File(atable, "snapshots/a_snapshot/data.db").createNewFile();
+        new File(atable, "snapshots/a_snapshot/index.db").createNewFile();
+        new File(atable, "snapshots/a_snapshot/nb-203-big-TOC.txt").createNewFile();
 
         File atableWithUUID = new File(dataDir, "data/ks1/a_table-a72c8740a57611ec935db766a70c44a1");
-        assertThat(atableWithUUID.mkdirs());
+        atableWithUUID.mkdirs();
         File atableWithUUIDSnapshot = new File(atableWithUUID, "snapshots/a_snapshot");
-        assertThat(atableWithUUIDSnapshot.mkdirs());
+        atableWithUUIDSnapshot.mkdirs();
 
-        assertThat(new File(atableWithUUID, "snapshots/a_snapshot/data.db").createNewFile());
-        assertThat(new File(atableWithUUID, "snapshots/a_snapshot/index.db").createNewFile());
-        assertThat(new File(atableWithUUID, "snapshots/a_snapshot/nb-203-big-TOC.txt").createNewFile());
-        assertThat(atableWithUUID.setLastModified(System.currentTimeMillis() + 2000000));
+        new File(atableWithUUID, "snapshots/a_snapshot/data.db").createNewFile();
+        new File(atableWithUUID, "snapshots/a_snapshot/index.db").createNewFile();
+        new File(atableWithUUID, "snapshots/a_snapshot/nb-203-big-TOC.txt").createNewFile();
+        atableWithUUID.setLastModified(System.currentTimeMillis() + 2000000);
 
         String expectedPath;
         // a_table and a_table-<TABLE_UUID> - the latter should be picked
-        SnapshotPathBuilder newBuilder = new SnapshotPathBuilder(vertx.fileSystem(), mockInstancesConfig);
+        SnapshotPathBuilder newBuilder = new SnapshotPathBuilder(vertx, mockInstancesConfig);
         expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot/data.db";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks1",
-                                                                             "a_table",
-                                                                             "a_snapshot",
-                                                                             "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks1",
+                                                                        "a_table",
+                                                                        "a_snapshot",
+                                                                        "data.db")),
+                               expectedPath);
+
+        expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks1",
+                                                                   "a_table",
+                                                                   "a_snapshot",
+                                                                   false)),
+                               expectedPath);
+
         expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot/index.db";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks1",
-                                                                             "a_table",
-                                                                             "a_snapshot",
-                                                                             "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks1",
+                                                                        "a_table",
+                                                                        "a_snapshot",
+                                                                        "index.db")),
+                               expectedPath);
+
+        expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks1",
+                                                                   "a_table",
+                                                                   "a_snapshot",
+                                                                   false)),
+                               expectedPath);
+
         expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks1",
-                                                                             "a_table",
-                                                                             "a_snapshot",
-                                                                             "nb-203-big-TOC.txt")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks1",
+                                                                        "a_table",
+                                                                        "a_snapshot",
+                                                                        "nb-203-big-TOC.txt")),
+                               expectedPath);
+
+        expectedPath = atableWithUUID.getAbsolutePath() + "/snapshots/a_snapshot";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks1",
+                                                                   "a_table",
+                                                                   "a_snapshot",
+                                                                   false)),
+                               expectedPath);
     }
 
+    @SuppressWarnings("ResultOfMethodCallIgnored")
     @Test
-    void testLastModifiedTablePicked() throws IOException, InterruptedException
+    void testLastModifiedTablePicked() throws IOException
     {
         TemporaryFolder tempFolder = new TemporaryFolder();
         tempFolder.create();
@@ -576,65 +581,95 @@
         InstanceMetadata mockInstanceMeta = mock(InstanceMetadata.class);
 
         when(mockInstancesConfig.instanceFromHost("localhost")).thenReturn(mockInstanceMeta);
-        when(mockInstanceMeta.dataDirs()).thenReturn(Arrays.asList(dataDir.getAbsolutePath()));
+        when(mockInstanceMeta.dataDirs()).thenReturn(Collections.singletonList(dataDir.getAbsolutePath()));
 
         File table4Old = new File(dataDir, "data/ks4/table4-a6442310a57611ec8b980b0b2009844e1");
-        assertThat(table4Old.mkdirs());
+        table4Old.mkdirs();
 
         // table was dropped and recreated. The table gets a new uuid
         File table4OldSnapshot = new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u");
-        assertThat(table4OldSnapshot.mkdirs());
+        table4OldSnapshot.mkdirs();
         // create some files inside snapshot this_is_a_valid_snapshot_name_i_❤_u in dataDir1
-        assertThat(new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile());
-        assertThat(new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile());
-        assertThat(new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt")
-                   .createNewFile());
+        new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile();
+        new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile();
+        new File(table4Old, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt").createNewFile();
 
-        File table4New = new File(dataDir, "data/ks4/table4-a72c8740a57611ec935db766a70c44a11");
-        assertThat(table4New.mkdirs());
+        File table4New = new File(dataDir, "data/ks4/table4-a72c8740a57611ec935db766a70c44a1");
+        table4New.mkdirs();
 
         File table4NewSnapshot = new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u");
-        assertThat(table4NewSnapshot.mkdirs());
+        table4NewSnapshot.mkdirs();
 
-        assertThat(new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile());
-        assertThat(new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile());
-        assertThat(new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt")
-                   .createNewFile());
-        assertThat(table4New.setLastModified(System.currentTimeMillis() + 2000000));
+        new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db").createNewFile();
+        new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db").createNewFile();
+        new File(table4New, "snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt").createNewFile();
+        table4New.setLastModified(System.currentTimeMillis() + 2000000);
 
         String expectedPath;
-        SnapshotPathBuilder newBuilder = new SnapshotPathBuilder(vertx.fileSystem(), mockInstancesConfig);
+        SnapshotPathBuilder newBuilder = new SnapshotPathBuilder(vertx, mockInstancesConfig);
         // table4-a72c8740a57611ec935db766a70c44a1 is the last modified, so it is the correct directory
         expectedPath = table4New.getAbsolutePath()
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/data.db";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "data.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "data.db")),
+                               expectedPath);
+
+        expectedPath = table4New.getAbsolutePath()
+                       + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks4",
+                                                                   "table4",
+                                                                   "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                   false)),
+                               expectedPath);
+
         expectedPath = table4New.getAbsolutePath()
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/index.db";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "index.db")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "index.db")),
+                               expectedPath);
+
+        expectedPath = table4New.getAbsolutePath()
+                       + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks4",
+                                                                   "table4",
+                                                                   "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                   false)),
+                               expectedPath);
+
         expectedPath = table4New.getAbsolutePath()
                        + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u/nb-203-big-TOC.txt";
-        succeedsWhenComponentExists(newBuilder
-                                    .build("localhost",
-                                           new StreamSSTableComponentRequest("ks4",
-                                                                             "table4",
-                                                                             "this_is_a_valid_snapshot_name_i_❤_u",
-                                                                             "nb-203-big-TOC.txt")),
-                                    expectedPath);
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new StreamSSTableComponentRequest("ks4",
+                                                                        "table4",
+                                                                        "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                        "nb-203-big-TOC.txt")),
+                               expectedPath);
+
+        expectedPath = table4New.getAbsolutePath()
+                       + "/snapshots/this_is_a_valid_snapshot_name_i_❤_u";
+        succeedsWhenPathExists(newBuilder
+                               .build("localhost",
+                                      new ListSnapshotFilesRequest("ks4",
+                                                                   "table4",
+                                                                   "this_is_a_valid_snapshot_name_i_❤_u",
+                                                                   false)),
+                               expectedPath);
     }
 
-    protected void succeedsWhenComponentExists(Future<String> future, String expectedPath)
+    protected void succeedsWhenPathExists(Future<String> future, String expectedPath)
     {
         VertxTestContext testContext = new VertxTestContext();
         future.onComplete(testContext.succeedingThenComplete());
diff --git a/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilderTest.java b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilderTest.java
index 4cf8688..ac350ed 100644
--- a/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilderTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotPathBuilderTest.java
@@ -1,16 +1,71 @@
+/*
+ * 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.cassandra.sidecar.snapshots;
 
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+
+import org.junit.jupiter.api.Test;
 import org.junit.jupiter.api.extension.ExtendWith;
 
+import io.vertx.core.Future;
 import io.vertx.core.Vertx;
 import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
 import org.apache.cassandra.sidecar.cluster.InstancesConfig;
 
+import static org.assertj.core.api.Assertions.assertThat;
+
 @ExtendWith(VertxExtension.class)
 class SnapshotPathBuilderTest extends AbstractSnapshotPathBuilderTest
 {
     SnapshotPathBuilder initialize(Vertx vertx, InstancesConfig instancesConfig)
     {
-        return new SnapshotPathBuilder(vertx.fileSystem(), instancesConfig);
+        return new SnapshotPathBuilder(vertx, instancesConfig);
+    }
+
+    @Test
+    void testFindSnapshotDirectories()
+    {
+        Future<List<String>> future = instance.findSnapshotDirectories("localhost", "a_snapshot");
+
+        VertxTestContext testContext = new VertxTestContext();
+        future.onComplete(testContext.succeedingThenComplete());
+        // awaitCompletion has the semantics of a java.util.concurrent.CountDownLatch
+        try
+        {
+            assertThat(testContext.awaitCompletion(5, TimeUnit.SECONDS)).isTrue();
+        }
+        catch (InterruptedException e)
+        {
+            throw new RuntimeException(e);
+        }
+        assertThat(testContext.failed()).isFalse();
+        List<String> snapshotDirectories = future.result();
+        Collections.sort(snapshotDirectories);
+        assertThat(snapshotDirectories).isNotNull();
+        assertThat(snapshotDirectories.size()).isEqualTo(2);
+        // we use ends with here, because MacOS prepends the /private path for temporary directories
+        assertThat(snapshotDirectories.get(0))
+        .endsWith(dataDir0 + "/ks1/a_table-a72c8740a57611ec935db766a70c44a1/snapshots/a_snapshot");
+        assertThat(snapshotDirectories.get(1))
+        .endsWith(dataDir0 + "/ks1/a_table/snapshots/a_snapshot");
     }
 }
diff --git a/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotSearchTest.java b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotSearchTest.java
new file mode 100644
index 0000000..daecce5
--- /dev/null
+++ b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotSearchTest.java
@@ -0,0 +1,154 @@
+/*
+ * 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.cassandra.sidecar.snapshots;
+
+import java.io.File;
+import java.io.IOException;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+import java.util.concurrent.TimeUnit;
+import java.util.stream.Collectors;
+
+import org.apache.commons.lang3.tuple.Pair;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+
+import io.vertx.core.CompositeFuture;
+import io.vertx.core.Future;
+import io.vertx.core.Vertx;
+import io.vertx.core.file.FileProps;
+import io.vertx.junit5.VertxExtension;
+import io.vertx.junit5.VertxTestContext;
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+
+import static org.apache.cassandra.sidecar.snapshots.SnapshotUtils.getSnapshot1Instance1Files;
+import static org.apache.cassandra.sidecar.snapshots.SnapshotUtils.getSnapshot1Instance2Files;
+import static org.apache.cassandra.sidecar.snapshots.SnapshotUtils.mockInstancesConfig;
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Tests for searching snapshots
+ */
+@ExtendWith(VertxExtension.class)
+public class SnapshotSearchTest
+{
+    @TempDir
+    File temporaryFolder;
+
+    SnapshotPathBuilder instance;
+    Vertx vertx = Vertx.vertx();
+    String rootDir;
+
+    @BeforeEach
+    public void setup() throws IOException
+    {
+        rootDir = temporaryFolder.getCanonicalPath();
+        SnapshotUtils.initializeTmpDirectory(temporaryFolder);
+        InstancesConfig mockInstancesConfig = mockInstancesConfig(rootDir);
+        instance = new SnapshotPathBuilder(vertx, mockInstancesConfig);
+    }
+
+    @Test
+    public void testListSnapshotDirectoryIncludeSecondaryIndex() throws InterruptedException
+    {
+        findAndListSnapshotHelper("localhost", "snapshot1", true,
+                                  Arrays.asList(rootDir + "/d1/data/keyspace1/table1-1234/snapshots/snapshot1",
+                                                rootDir + "/d1/data/keyspace1/table2-1234/snapshots/snapshot1"),
+                                  Arrays.asList(rootDir + "/d1/data/keyspace1/table1-1234/snapshots/snapshot1"
+                                                + "/.index/secondary.db",
+                                                rootDir + "/d1/data/keyspace1/table1-1234/snapshots/snapshot1/1.db",
+                                                rootDir + "/d1/data/keyspace1/table2-1234/snapshots/snapshot1/3.db"));
+    }
+
+    @Test
+    public void testListSnapshotDirectoryDoNotIncludeSecondaryIndex() throws InterruptedException
+    {
+        findAndListSnapshotHelper("localhost", "snapshot1", false,
+                                  Arrays.asList(rootDir + "/d1/data/keyspace1/table1-1234/snapshots/snapshot1",
+                                                rootDir + "/d1/data/keyspace1/table2-1234/snapshots/snapshot1"),
+                                  getSnapshot1Instance1Files());
+    }
+
+    @Test
+    public void testListSnapshotDirectoryPerInstance() throws InterruptedException
+    {
+        // When host name is instance1's host name, we should get files of snapshot1 from instance 1
+        findAndListSnapshotHelper("localhost", "snapshot1", false,
+                                  Arrays.asList(rootDir + "/d1/data/keyspace1/table1-1234/snapshots/snapshot1",
+                                                rootDir + "/d1/data/keyspace1/table2-1234/snapshots/snapshot1"),
+                                  getSnapshot1Instance1Files());
+
+        // When host name is instance2's host name, we should get files of snapshot1 from instance 1
+        findAndListSnapshotHelper("localhost2", "snapshot1", false,
+                                  Arrays.asList(rootDir + "/d2/data/keyspace1/table1-1234/snapshots/snapshot1",
+                                                rootDir + "/d2/data/keyspace1/table2-1234/snapshots/snapshot1"),
+                                  getSnapshot1Instance2Files());
+    }
+
+    // Helper methods
+
+    private void findAndListSnapshotHelper(String host, String snapshotName,
+                                           boolean includeSecondaryIndexFiles,
+                                           List<String> expectedDirectories,
+                                           List<String> expectedFiles) throws InterruptedException
+    {
+        VertxTestContext testContext = new VertxTestContext();
+        Future<List<String>> future = instance.findSnapshotDirectories(host, snapshotName);
+        future.onComplete(testContext.succeedingThenComplete());
+        // awaitCompletion has the semantics of a java.util.concurrent.CountDownLatch
+        assertThat(testContext.awaitCompletion(5, TimeUnit.SECONDS)).isTrue();
+        assertThat(testContext.failed()).isFalse();
+        List<String> snapshotDirectories = future.result();
+        assertThat(snapshotDirectories).isNotNull();
+        Collections.sort(snapshotDirectories);
+        assertThat(snapshotDirectories).isEqualTo(expectedDirectories);
+
+        //noinspection rawtypes
+        List<Future> futures = snapshotDirectories.stream()
+                                                  .map(directory -> instance
+                                                                    .listSnapshotDirectory(directory,
+                                                                                           includeSecondaryIndexFiles))
+                                                  .collect(Collectors.toList());
+
+        VertxTestContext compositeFutureContext = new VertxTestContext();
+        CompositeFuture ar = CompositeFuture.all(futures);
+        ar.onComplete(compositeFutureContext.succeedingThenComplete());
+        assertThat(compositeFutureContext.awaitCompletion(5, TimeUnit.SECONDS)).isTrue();
+        assertThat(compositeFutureContext.failed()).isFalse();
+
+        // flat map results
+        //noinspection unchecked
+        List<String> snapshotFiles = ar.list()
+                                       .stream()
+                                       .flatMap(l -> ((List<Pair<String, FileProps>>) l).stream())
+                                       .map(Pair::getLeft)
+                                       .sorted()
+                                       .collect(Collectors.toList());
+
+        assertThat(snapshotFiles.size()).isEqualTo(expectedFiles.size());
+
+        for (int i = 0; i < expectedFiles.size(); i++)
+        {
+            assertThat(snapshotFiles.get(i)).endsWith(expectedFiles.get(i));
+        }
+    }
+}
diff --git a/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotUtils.java b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotUtils.java
new file mode 100644
index 0000000..f16605d
--- /dev/null
+++ b/src/test/java/org/apache/cassandra/sidecar/snapshots/SnapshotUtils.java
@@ -0,0 +1,168 @@
+/*
+ * 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.cassandra.sidecar.snapshots;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.charset.Charset;
+import java.util.ArrayList;
+import java.util.Arrays;
+import java.util.Collections;
+import java.util.List;
+
+import org.apache.commons.io.FileUtils;
+
+import org.apache.cassandra.sidecar.cluster.InstancesConfig;
+import org.apache.cassandra.sidecar.cluster.InstancesConfigImpl;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadata;
+import org.apache.cassandra.sidecar.cluster.instance.InstanceMetadataImpl;
+import org.apache.cassandra.sidecar.common.CassandraVersionProvider;
+import org.apache.cassandra.sidecar.common.MockCassandraFactory;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Utilities for testing snapshot related features
+ */
+public class SnapshotUtils
+{
+    @SuppressWarnings("ResultOfMethodCallIgnored")
+    public static void initializeTmpDirectory(File temporaryFolder) throws IOException
+    {
+        for (final String[] folderPath : getMockSnapshotDirectories())
+        {
+            assertThat(new File(temporaryFolder, String.join("/", folderPath)).mkdirs()).isTrue();
+        }
+        for (final String[] folderPath : getMockNonSnapshotDirectories())
+        {
+            assertThat(new File(temporaryFolder, String.join("/", folderPath)).mkdirs()).isTrue();
+        }
+        for (final String fileName : getMockSnapshotFiles())
+        {
+            File snapshotFile = new File(temporaryFolder, fileName);
+            FileUtils.writeStringToFile(snapshotFile, "hello world", Charset.defaultCharset());
+        }
+        for (final String fileName : getMockNonSnapshotFiles())
+        {
+            File nonSnapshotFile = new File(temporaryFolder, fileName);
+            FileUtils.touch(nonSnapshotFile);
+        }
+        // adding secondary index files
+        assertThat(new File(temporaryFolder, "d1/data/keyspace1/table1-1234/snapshots/snapshot1/.index/")
+                   .mkdirs()).isTrue();
+        FileUtils.touch(new File(temporaryFolder,
+                                 "d1/data/keyspace1/table1-1234/snapshots/snapshot1/.index/secondary.db"));
+
+        assertThat(new File(temporaryFolder, "d1/data/keyspace1/table1-1234")
+                   .setLastModified(System.currentTimeMillis() + 2_000_000)).isTrue();
+    }
+
+    public static InstancesConfig mockInstancesConfig(String rootPath)
+    {
+        CassandraVersionProvider.Builder versionProviderBuilder = new CassandraVersionProvider.Builder();
+        versionProviderBuilder.add(new MockCassandraFactory());
+        CassandraVersionProvider versionProvider = versionProviderBuilder.build();
+        InstanceMetadataImpl localhost = new InstanceMetadataImpl(1,
+                                                                  "localhost",
+                                                                  9043,
+                                                                  Collections.singletonList(rootPath + "/d1/data"),
+                                                                  null,
+                                                                  versionProvider,
+                                                                  1000);
+        InstanceMetadataImpl localhost2 = new InstanceMetadataImpl(2,
+                                                                   "localhost2",
+                                                                   9043,
+                                                                   Collections.singletonList(rootPath + "/d2/data"),
+                                                                   null,
+                                                                   versionProvider,
+                                                                   1000);
+        List<InstanceMetadata> instanceMetas = Arrays.asList(localhost, localhost2);
+        return new InstancesConfigImpl(instanceMetas);
+    }
+
+    public static List<String[]> getMockSnapshotDirectories()
+    {
+        return Arrays.asList(new String[]{ "d1", "data", "keyspace1", "table1-1234", "snapshots", "snapshot1" },
+                             new String[]{ "d1", "data", "keyspace1", "table1-1234", "snapshots", "snapshot2" },
+                             new String[]{ "d1", "data", "keyspace1", "table2-1234", "snapshots", "snapshot1" },
+                             new String[]{ "d1", "data", "keyspace1", "table2-1234", "snapshots", "snapshot2" },
+                             new String[]{ "d2", "data", "keyspace1", "table1-1234", "snapshots", "snapshot1" },
+                             new String[]{ "d2", "data", "keyspace1", "table1-1234", "snapshots", "snapshot2" },
+                             new String[]{ "d2", "data", "keyspace1", "table2-1234", "snapshots", "snapshot1" },
+                             new String[]{ "d2", "data", "keyspace1", "table2-1234", "snapshots", "snapshot2" });
+    }
+
+    public static List<String[]> getMockNonSnapshotDirectories()
+    {
+        return Arrays.asList(new String[]{ "d1", "data", "keyspace1", "table1", "nonsnapshots", "snapshot1" },
+                             new String[]{ "d1", "data", "keyspace1", "table2", "nonsnapshots", "snapshot1" },
+                             new String[]{ "d2", "data", "keyspace1", "table1", "nonsnapshots", "snapshot1" },
+                             new String[]{ "d2", "data", "keyspace1", "table2", "nonsnapshots", "snapshot1" });
+    }
+
+    public static List<String> getMockSnapshotFiles()
+    {
+        List<String> snapshotFiles = new ArrayList<>();
+        snapshotFiles.addAll(getSnapshot1Files());
+        snapshotFiles.addAll(getSnapshot2Files());
+        Collections.sort(snapshotFiles);
+        return snapshotFiles;
+    }
+
+    public static List<String> getSnapshot1Files()
+    {
+        final List<String> snapshotFiles = new ArrayList<>();
+        snapshotFiles.addAll(getSnapshot1Instance1Files());
+        snapshotFiles.addAll(getSnapshot1Instance2Files());
+        Collections.sort(snapshotFiles);
+        return snapshotFiles;
+    }
+
+    public static List<String> getSnapshot1Instance1Files()
+    {
+        return Arrays.asList("d1/data/keyspace1/table1-1234/snapshots/snapshot1/1.db",
+                             "d1/data/keyspace1/table2-1234/snapshots/snapshot1/3.db");
+    }
+
+    public static List<String> getSnapshot1Instance2Files()
+    {
+        return Arrays.asList("d2/data/keyspace1/table1-1234/snapshots/snapshot1/5.db",
+                             "d2/data/keyspace1/table2-1234/snapshots/snapshot1/7.db");
+    }
+
+    public static List<String> getSnapshot2Files()
+    {
+        List<String> snapshotFiles = Arrays.asList("d1/data/keyspace1/table1-1234/snapshots/snapshot2/2.db",
+                                                   "d1/data/keyspace1/table2-1234/snapshots/snapshot2/4.db",
+                                                   "d2/data/keyspace1/table1-1234/snapshots/snapshot2/6.db",
+                                                   "d2/data/keyspace1/table2-1234/snapshots/snapshot2/8.db");
+        Collections.sort(snapshotFiles);
+        return snapshotFiles;
+    }
+
+    public static List<String> getMockNonSnapshotFiles()
+    {
+        List<String> nonSnapshotFiles = Arrays.asList("d1/data/keyspace1/table1/11.db",
+                                                      "d1/data/keyspace1/table2/12.db",
+                                                      "d2/data/keyspace1/table1/13.db",
+                                                      "d2/data/keyspace1/table2/14.db");
+        Collections.sort(nonSnapshotFiles);
+        return nonSnapshotFiles;
+    }
+}
diff --git a/src/test/java/org/apache/cassandra/sidecar/utils/RequestUtilsTest.java b/src/test/java/org/apache/cassandra/sidecar/utils/RequestUtilsTest.java
index 8788fb3..7be91cf 100644
--- a/src/test/java/org/apache/cassandra/sidecar/utils/RequestUtilsTest.java
+++ b/src/test/java/org/apache/cassandra/sidecar/utils/RequestUtilsTest.java
@@ -1,3 +1,21 @@
+/*
+ * 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.cassandra.sidecar.utils;
 
 import org.junit.jupiter.api.Test;