This is an automated email from the ASF dual-hosted git repository.
mchades pushed a commit to branch branch-1.3
in repository https://gitbox.apache.org/repos/asf/gravitino.git
The following commit(s) were added to refs/heads/branch-1.3 by this push:
new 52f6362ba1 [Cherry-pick to branch-1.3] [MINOR] fix(fileset): validate
sub-path resolution (#13721) (#13796)
52f6362ba1 is described below
commit 52f6362ba175919d40438ecd8aa7e5f48734bfa6
Author: github-actions[bot]
<41898282+github-actions[bot]@users.noreply.github.com>
AuthorDate: Tue Oct 13 15:43:53 2026 +0800
[Cherry-pick to branch-1.3] [MINOR] fix(fileset): validate sub-path
resolution (#13721) (#13796)
### What changes were proposed in this pull request?
Backport #13721 (878240b75deeeb754e431fb110c6f2553d73c7fb) to
`branch-1.3`.
Resolve the test-file conflict, retain the path-validation coverage, and
adapt the test helper to the 1.3 constructor.
### Why are the changes needed?
Keep fileset sub-path resolution within the selected storage location.
### Does this PR introduce _any_ user-facing change?
File listing and location lookup reject invalid sub-paths with HTTP 400
while preserving valid path behavior.
### How was this patch tested?
- `TestFilesetCatalogOperations`: 225 tests passed.
- `TestFilesetOperations`: 19 tests passed.
- `spotlessApply`, the catalog/server Java format checks, and
`:docs:build` passed.
- Docker integration tests were not run locally.
---------
Co-authored-by: mchades <[email protected]>
---
.../org/apache/gravitino/file/FilesetCatalog.java | 12 +
.../catalog/fileset/FilesetCatalogOperations.java | 64 +++--
.../fileset/TestFilesetCatalogOperations.java | 272 +++++++++++++++++++++
.../apache/gravitino/client/FilesetCatalog.java | 11 +-
.../apache/gravitino/catalog/FilesetFileOps.java | 6 +
docs/open-api/filesets.yaml | 33 ++-
.../server/web/rest/TestFilesetOperations.java | 54 +++-
7 files changed, 418 insertions(+), 34 deletions(-)
diff --git a/api/src/main/java/org/apache/gravitino/file/FilesetCatalog.java
b/api/src/main/java/org/apache/gravitino/file/FilesetCatalog.java
index 448b1990a7..c0c1885917 100644
--- a/api/src/main/java/org/apache/gravitino/file/FilesetCatalog.java
+++ b/api/src/main/java/org/apache/gravitino/file/FilesetCatalog.java
@@ -160,9 +160,15 @@ public interface FilesetCatalog {
* Get the actual location of a file or directory based on the default
storage location of Fileset
* and the sub path.
*
+ * <p>Leading and trailing whitespace in the sub path is trimmed. An empty
path or {@code /}
+ * refers to the default storage location. A leading slash is relative to
that location.
+ *
* @param ident A fileset identifier.
* @param subPath The sub path to the file or directory.
* @return The actual location of the file or directory.
+ * @throws IllegalArgumentException If the sub path contains a complete
{@code ..} path segment or
+ * a NUL character ({@code U+0000}), or its normalized path is outside
the default storage
+ * location.
* @throws NoSuchFilesetException If the fileset does not exist.
*/
default String getFileLocation(NameIdentifier ident, String subPath)
@@ -174,10 +180,16 @@ public interface FilesetCatalog {
* Get the actual location of a file or directory based on the storage
location of Fileset and the
* sub path by the location name.
*
+ * <p>Leading and trailing whitespace in the sub path is trimmed. An empty
path or {@code /}
+ * refers to the selected storage location. A leading slash is relative to
that location.
+ *
* @param ident A fileset identifier.
* @param subPath The sub path to the file or directory.
* @param locationName The location name. If null, the default location will
be used.
* @return The actual location of the file or directory.
+ * @throws IllegalArgumentException If the sub path contains a complete
{@code ..} path segment or
+ * a NUL character ({@code U+0000}), or its normalized path is outside
the selected storage
+ * location.
* @throws NoSuchFilesetException If the fileset does not exist.
* @throws NoSuchLocationNameException If the location name does not exist.
*/
diff --git
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
index b725c27125..433c52401b 100644
---
a/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
+++
b/catalogs/catalog-fileset/src/main/java/org/apache/gravitino/catalog/fileset/FilesetCatalogOperations.java
@@ -1721,11 +1721,16 @@ public class FilesetCatalogOperations extends
ManagedSchemaOperations
private String getFileLocation(Fileset fileset, String subPath, String
locationName) {
Preconditions.checkArgument(subPath != null, "subPath must not be null");
- String processedSubPath;
- if (!subPath.trim().isEmpty() && !subPath.trim().startsWith(SLASH)) {
- processedSubPath = SLASH + subPath.trim();
- } else {
- processedSubPath = subPath.trim();
+ String processedSubPath = subPath.trim();
+ // Validate before constructing a Hadoop Path, which normalizes away
parent directory segments.
+ Preconditions.checkArgument(
+ subPath.indexOf('\0') < 0, "subPath must not contain null characters");
+ for (String segment : processedSubPath.split(SLASH)) {
+ Preconditions.checkArgument(
+ !"..".equals(segment), "subPath must not contain parent directory
segments");
+ }
+ if (!processedSubPath.isEmpty() && !processedSubPath.startsWith(SLASH)) {
+ processedSubPath = SLASH + processedSubPath;
}
String targetLocationName;
@@ -1744,6 +1749,20 @@ public class FilesetCatalogOperations extends
ManagedSchemaOperations
"Location name %s does not exist in fileset %s", targetLocationName,
fileset.name());
}
+ String storageLocation =
fileset.storageLocations().get(targetLocationName);
+ String fileLocation =
+ processedSubPath.isEmpty()
+ ? storageLocation
+ : removeTrailingSlash(storageLocation) + processedSubPath;
+
+ // Compare normalized Path ancestors, including scheme and authority,
rather than string
+ // prefixes. Keep the original spelling in the response for existing
clients.
+ Path storagePath = normalizeLocationPath(storageLocation);
+ Path filePath = normalizeLocationPath(fileLocation);
+ Preconditions.checkArgument(
+ isWithinStorageLocation(storagePath, filePath),
+ "subPath must stay within the fileset storage location");
+
// do checks for some data operations.
if (hasCallerContext()) {
Map<String, String> contextMap =
CallerContext.CallerContextHolder.get().context();
@@ -1759,10 +1778,9 @@ public class FilesetCatalogOperations extends
ManagedSchemaOperations
FilesetDataOperation dataOperation =
FilesetDataOperation.valueOf(operation);
switch (dataOperation) {
case RENAME:
- // if the sub path is blank, it cannot be renamed otherwise the
metadata in the
- // Gravitino server may be inconsistent.
- if (StringUtils.isBlank(processedSubPath)
- || (processedSubPath.startsWith(SLASH) &&
processedSubPath.length() == 1)) {
+ // Include aliases such as /./ so the fileset root cannot be
renamed independently of
+ // its metadata.
+ if (filePath.equals(storagePath)) {
throw new GravitinoRuntimeException(
"subPath cannot be blank when need to rename a file or a
directory.");
}
@@ -1773,20 +1791,26 @@ public class FilesetCatalogOperations extends
ManagedSchemaOperations
}
}
- String fileLocation;
- // If the processed sub path is blank, we pass the storage location
directly
- if (StringUtils.isBlank(processedSubPath)) {
- fileLocation = fileset.storageLocations().get(targetLocationName);
- } else {
- // the processed sub path always starts with "/" if it is not blank,
- // so we can safely remove the tailing slash if the storage location
ends with "/".
- String storageLocation =
-
removeTrailingSlash(fileset.storageLocations().get(targetLocationName));
- fileLocation = String.format("%s%s", storageLocation, processedSubPath);
- }
return fileLocation;
}
+ private Path normalizeLocationPath(String location) {
+ // Represent authority-only locations (for example s3a://bucket) as
filesystem roots. Hadoop
+ // removes trailing slashes before normalizing dot segments, so parse
again to remove a trailing
+ // slash introduced by normalization of an alias such as /directory/./.
+ Path path = new Path(ensureTrailingSlash(location));
+ return new Path(path.toString());
+ }
+
+ private static boolean isWithinStorageLocation(Path storagePath, Path
filePath) {
+ for (Path current = filePath; current != null; current =
current.getParent()) {
+ if (storagePath.equals(current)) {
+ return true;
+ }
+ }
+ return false;
+ }
+
@VisibleForTesting
Map<String, String> mergeUpLevelConfigurations(
NameIdentifier ident, Map<String, String> entityProperties, Path path) {
diff --git
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
index d3f670070b..378dd580b7 100644
---
a/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
+++
b/catalogs/catalog-fileset/src/test/java/org/apache/gravitino/catalog/fileset/TestFilesetCatalogOperations.java
@@ -56,6 +56,7 @@ import com.google.common.collect.Maps;
import java.io.File;
import java.io.IOException;
import java.net.ConnectException;
+import java.nio.file.Files;
import java.nio.file.Paths;
import java.util.Arrays;
import java.util.Collections;
@@ -123,12 +124,16 @@ import org.apache.hadoop.fs.Path;
import org.awaitility.Awaitility;
import org.junit.jupiter.api.AfterAll;
import org.junit.jupiter.api.Assertions;
+import org.junit.jupiter.api.Assumptions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.Timeout;
+import org.junit.jupiter.api.io.TempDir;
import org.junit.jupiter.params.ParameterizedTest;
import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.EnumSource;
import org.junit.jupiter.params.provider.MethodSource;
+import org.junit.jupiter.params.provider.ValueSource;
import org.mockito.MockedStatic;
import org.mockito.Mockito;
@@ -143,6 +148,9 @@ public class TestFilesetCatalogOperations {
private static final String TEST_ROOT_PATH = "file:" +
UNFORMALIZED_TEST_ROOT_PATH;
+ private static final NameIdentifier PATH_VALIDATION_FILESET =
+ NameIdentifier.of("metalake", "catalog", "schema", "fileset");
+
private static final HasPropertyMetadata FILESET_PROPERTIES_METADATA =
new HasPropertyMetadata() {
@Override
@@ -1760,6 +1768,215 @@ public class TestFilesetCatalogOperations {
}
}
+ @ParameterizedTest
+ @ValueSource(
+ strings = {
+ "..",
+ "../outside",
+ "/../outside",
+ "/dir/../../outside",
+ "dir/../file",
+ " /../outside ",
+ "/file\u0000name",
+ "\u0000file",
+ "/file\u0000"
+ })
+ void testRejectUnsafeSubPaths(String subPath) throws IOException {
+ try (FilesetCatalogOperations operations =
+ pathValidationOperations(mockFilesetForPathValidation())) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> operations.getFileLocation(PATH_VALIDATION_FILESET, subPath,
null));
+ }
+ }
+
+ @ParameterizedTest
+ @MethodSource("validLocations")
+ void testPreserveValidLocations(String storageLocation, String subPath,
String expected)
+ throws IOException {
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations()).thenReturn(ImmutableMap.of("primary",
storageLocation));
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset)) {
+ Assertions.assertEquals(
+ expected, operations.getFileLocation(PATH_VALIDATION_FILESET,
subPath, null));
+ }
+ }
+
+ @Test
+ void testSelectedLocationDefinesBoundary() throws IOException {
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations())
+ .thenReturn(
+ ImmutableMap.of(
+ "primary", "s3a://bucket/primary", "archive",
"hdfs://namenode:8020/archive"));
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset)) {
+ Assertions.assertEquals(
+ "s3a://bucket/primary/data",
+ operations.getFileLocation(PATH_VALIDATION_FILESET, "/data", null));
+ Assertions.assertEquals(
+ "hdfs://namenode:8020/archive/data",
+ operations.getFileLocation(PATH_VALIDATION_FILESET, "/data",
"archive"));
+ for (String location : fileset.storageLocations().keySet()) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> operations.getFileLocation(PATH_VALIDATION_FILESET,
"/../outside", location));
+ }
+ }
+ }
+
+ @Test
+ void testRejectAuthorityChangeAtFilesystemRoot() throws IOException {
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations()).thenReturn(ImmutableMap.of("primary",
"/"));
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset)) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> operations.getFileLocation(PATH_VALIDATION_FILESET,
"//other-host/outside", null));
+ }
+ }
+
+ @ParameterizedTest
+ @EnumSource(FilesetDataOperation.class)
+ void testOperationHeadersCannotBypassValidation(FilesetDataOperation
operation)
+ throws IOException {
+ try (FilesetCatalogOperations operations =
+ pathValidationOperations(mockFilesetForPathValidation())) {
+ CallerContext.CallerContextHolder.set(
+ CallerContext.builder()
+ .withContext(
+ ImmutableMap.of(
+
FilesetAuditConstants.HTTP_HEADER_FILESET_DATA_OPERATION, operation.name()))
+ .build());
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> operations.getFileLocation(PATH_VALIDATION_FILESET,
"/../outside", null));
+ } finally {
+ CallerContext.CallerContextHolder.remove();
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"", "/", "/.", "/./", "//", "///"})
+ void testRenameCannotTargetRootAliases(String subPath) throws IOException {
+ try (FilesetCatalogOperations operations =
+ pathValidationOperations(mockFilesetForPathValidation())) {
+ CallerContext.CallerContextHolder.set(
+ CallerContext.builder()
+ .withContext(
+ ImmutableMap.of(
+ FilesetAuditConstants.HTTP_HEADER_FILESET_DATA_OPERATION,
+ FilesetDataOperation.RENAME.name()))
+ .build());
+ Assertions.assertThrows(
+ GravitinoRuntimeException.class,
+ () -> operations.getFileLocation(PATH_VALIDATION_FILESET, subPath,
null));
+ } finally {
+ CallerContext.CallerContextHolder.remove();
+ }
+ }
+
+ @Test
+ void testRenameAllowsNonRootPathAlias() throws IOException {
+ try (FilesetCatalogOperations operations =
+ pathValidationOperations(mockFilesetForPathValidation())) {
+ CallerContext.CallerContextHolder.set(
+ CallerContext.builder()
+ .withContext(
+ ImmutableMap.of(
+ FilesetAuditConstants.HTTP_HEADER_FILESET_DATA_OPERATION,
+ FilesetDataOperation.RENAME.name()))
+ .build());
+ Assertions.assertEquals(
+ "file:///storage/fileset/dir/./file",
+ operations.getFileLocation(PATH_VALIDATION_FILESET, "/dir/./file",
null));
+ } finally {
+ CallerContext.CallerContextHolder.remove();
+ }
+ }
+
+ @Test
+ void testListFilesCannotReachSiblingSentinel(@TempDir File tempDir) throws
IOException {
+ File root = new File(tempDir, "root");
+ File outside = new File(tempDir, "root-sibling");
+ Files.createDirectories(root.toPath());
+ Files.createDirectories(outside.toPath());
+ Files.writeString(new File(outside, "sentinel.txt").toPath(), "outside the
fileset");
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations())
+ .thenReturn(ImmutableMap.of("primary", root.toURI().toString()));
+
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset);
+ FileSystem fs = FileSystem.newInstanceLocal(new Configuration(false)))
{
+ doReturn(Collections.emptyMap())
+ .when(operations)
+ .mergeUpLevelConfigurations(any(), any(), any());
+ doReturn(fs).when(operations).getFileSystemWithCache(any(), any());
+ Assertions.assertEquals(1, fs.listStatus(new
Path(outside.toURI())).length);
+ Assertions.assertEquals(0, operations.listFiles(PATH_VALIDATION_FILESET,
null, "/").length);
+ Mockito.clearInvocations(operations);
+
+ for (String subPath :
+ new String[] {"/../root-sibling", "/../root-sibling/sentinel.txt",
"/../missing"}) {
+ Assertions.assertThrows(
+ IllegalArgumentException.class,
+ () -> operations.listFiles(PATH_VALIDATION_FILESET, null,
subPath));
+ }
+ Mockito.verify(operations,
Mockito.never()).mergeUpLevelConfigurations(any(), any(), any());
+ Mockito.verify(operations,
Mockito.never()).getFileSystemWithCache(any(), any());
+ }
+ }
+
+ @Test
+ void testListFilesPreservesLiteralEncodedNames(@TempDir File tempDir) throws
IOException {
+ File root = new File(tempDir, "root");
+ File child = new File(root, "%2e%2e");
+ Files.createDirectories(child.toPath());
+ Files.writeString(new File(child, "a..b + caf\u00e9.txt").toPath(),
"inside the fileset");
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations())
+ .thenReturn(ImmutableMap.of("primary", root.toURI().toString()));
+
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset);
+ FileSystem fs = FileSystem.newInstanceLocal(new Configuration(false)))
{
+ doReturn(Collections.emptyMap())
+ .when(operations)
+ .mergeUpLevelConfigurations(any(), any(), any());
+ doReturn(fs).when(operations).getFileSystemWithCache(any(), any());
+ FileInfo[] files = operations.listFiles(PATH_VALIDATION_FILESET, null,
"/%2e%2e");
+ Assertions.assertEquals(1, files.length);
+ Assertions.assertEquals("a..b + caf\u00e9.txt", files[0].name());
+ }
+ }
+
+ @ParameterizedTest
+ @ValueSource(strings = {"dir\\name", "..\\outside", "dir\\..\\outside"})
+ void testListFilesPreservesLiteralBackslashes(String childName, @TempDir
File tempDir)
+ throws IOException {
+ Assumptions.assumeFalse(Path.WINDOWS, "Backslashes are path separators on
Windows");
+ File root = new File(tempDir, "root");
+ File child = new File(root, childName);
+ Files.createDirectories(child.toPath());
+ Files.writeString(new File(child, "file\\name.txt").toPath(), "inside the
fileset");
+ String storageLocation = root.toURI().toString();
+ Fileset fileset = mockFilesetForPathValidation();
+ when(fileset.storageLocations()).thenReturn(ImmutableMap.of("primary",
storageLocation));
+
+ try (FilesetCatalogOperations operations =
pathValidationOperations(fileset);
+ FileSystem fs = FileSystem.newInstanceLocal(new Configuration(false)))
{
+ doReturn(Collections.emptyMap())
+ .when(operations)
+ .mergeUpLevelConfigurations(any(), any(), any());
+ doReturn(fs).when(operations).getFileSystemWithCache(any(), any());
+ String subPath = "/" + childName;
+ Assertions.assertEquals(
+ storageLocation + childName,
+ operations.getFileLocation(PATH_VALIDATION_FILESET, subPath, null));
+ FileInfo[] files = operations.listFiles(PATH_VALIDATION_FILESET, null,
subPath);
+ Assertions.assertEquals(1, files.length);
+ Assertions.assertEquals("file\\name.txt", files[0].name());
+ }
+ }
+
@Test
public void testGetFileLocation() throws IOException {
final long testId = generateTestId();
@@ -1860,6 +2077,8 @@ public class TestFilesetCatalogOperations {
CallerContext.CallerContextHolder.set(callerContext);
Assertions.assertThrows(
GravitinoRuntimeException.class, () ->
ops.getFileLocation(filesetIdent, ""));
+ } finally {
+ CallerContext.CallerContextHolder.remove();
}
// test storage location end with "/"
@@ -3650,4 +3869,57 @@ public class TestFilesetCatalogOperations {
Assertions.assertNull(result3.get("endpoint"));
}
}
+
+ private static Stream<Arguments> validLocations() {
+ return Stream.of(
+ Arguments.of("file:///storage/fileset", "", "file:///storage/fileset"),
+ Arguments.of("file:///storage/fileset", "/",
"file:///storage/fileset/"),
+ Arguments.of("file:///storage/fileset/", "dir/file",
"file:///storage/fileset/dir/file"),
+ Arguments.of("file:///storage/fileset/.", "/child",
"file:///storage/fileset/./child"),
+ Arguments.of("file:///storage/fileset", "/dir/file",
"file:///storage/fileset/dir/file"),
+ Arguments.of("file:///storage/fileset", "/a..b",
"file:///storage/fileset/a..b"),
+ Arguments.of(
+ "file:///storage/fileset", "/dir/./file",
"file:///storage/fileset/dir/./file"),
+ Arguments.of("file:///storage/fileset", "/%2e%2e",
"file:///storage/fileset/%2e%2e"),
+ Arguments.of("file:///storage/fileset", "/a + b",
"file:///storage/fileset/a + b"),
+ Arguments.of("file:///", "/child", "file:///child"),
+ Arguments.of("/", "/child", "/child"),
+ Arguments.of("/storage/fileset", "child", "/storage/fileset/child"),
+ Arguments.of(
+ "hdfs://namenode:8020/fileset", "/child",
"hdfs://namenode:8020/fileset/child"),
+ Arguments.of(
+ "hdfs://namenode:8020/fileset",
+ "/dir\\name.txt",
+ "hdfs://namenode:8020/fileset/dir\\name.txt"),
+ Arguments.of("s3a://bucket/prefix", "/dir\\name.txt",
"s3a://bucket/prefix/dir\\name.txt"),
+ Arguments.of("gs://bucket/prefix", "/dir\\name.txt",
"gs://bucket/prefix/dir\\name.txt"),
+ Arguments.of("s3a://bucket/prefix", "/..\\outside",
"s3a://bucket/prefix/..\\outside"),
+ Arguments.of("s3a://bucket/prefix", "\\..\\outside",
"s3a://bucket/prefix/\\..\\outside"),
+ Arguments.of(
+ "s3a://bucket/prefix", "/dir\\..\\outside",
"s3a://bucket/prefix/dir\\..\\outside"),
+ Arguments.of("s3a://bucket/prefix", "/child",
"s3a://bucket/prefix/child"),
+ Arguments.of("s3a://bucket", "/child", "s3a://bucket/child"),
+ Arguments.of("s3a://bucket", "", "s3a://bucket"),
+ Arguments.of(
+ "abfs://[email protected]/prefix",
+ "/child",
+ "abfs://[email protected]/prefix/child"));
+ }
+
+ private Fileset mockFilesetForPathValidation() {
+ Fileset fileset = Mockito.mock(Fileset.class);
+ when(fileset.name()).thenReturn(PATH_VALIDATION_FILESET.name());
+ when(fileset.properties())
+ .thenReturn(ImmutableMap.of(PROPERTY_DEFAULT_LOCATION_NAME,
"primary"));
+ when(fileset.storageLocations())
+ .thenReturn(ImmutableMap.of("primary", "file:///storage/fileset"));
+ return fileset;
+ }
+
+ private FilesetCatalogOperations pathValidationOperations(Fileset fileset) {
+ FilesetCatalogOperations operations =
+ Mockito.spy(new
FilesetCatalogOperations(Mockito.mock(EntityStore.class)));
+ doReturn(fileset).when(operations).loadFileset(PATH_VALIDATION_FILESET);
+ return operations;
+ }
}
diff --git
a/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
b/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
index 33c9b8d131..9a11e5e919 100644
---
a/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
+++
b/clients/client-java/src/main/java/org/apache/gravitino/client/FilesetCatalog.java
@@ -229,16 +229,7 @@ class FilesetCatalog extends BaseSchemaCatalog
return resp.dropped();
}
- /**
- * Get the actual path of a file or directory based on the storage location
of Fileset and the sub
- * path.
- *
- * @param ident A fileset identifier.
- * @param subPath The sub path to the file or directory.
- * @param locationName The name of the location to be accessed.
- * @return The actual location of the file or directory.
- * @throws NoSuchFilesetException If the fileset does not exist.
- */
+ /** {@inheritDoc} */
@Override
public String getFileLocation(NameIdentifier ident, String subPath, String
locationName)
throws NoSuchFilesetException, NoSuchLocationNameException {
diff --git
a/core/src/main/java/org/apache/gravitino/catalog/FilesetFileOps.java
b/core/src/main/java/org/apache/gravitino/catalog/FilesetFileOps.java
index c06db4eb42..8c9fd7795d 100644
--- a/core/src/main/java/org/apache/gravitino/catalog/FilesetFileOps.java
+++ b/core/src/main/java/org/apache/gravitino/catalog/FilesetFileOps.java
@@ -34,10 +34,16 @@ public interface FilesetFileOps {
/**
* List the files in a fileset with a specific location name and sub path.
*
+ * <p>Leading and trailing whitespace in the sub path is trimmed. An empty
path or {@code /}
+ * refers to the selected storage location. A leading slash is relative to
that location.
+ *
* @param ident A fileset identifier.
* @param locationName The location name. If null, the default location will
be used.
* @param subPath The sub path under the fileset.
* @return An array of file information objects.
+ * @throws IllegalArgumentException If the sub path contains a complete
{@code ..} path segment or
+ * a NUL character ({@code U+0000}), resolves outside the selected
storage location, or refers
+ * to a path that does not exist.
*/
default FileInfo[] listFiles(NameIdentifier ident, String locationName,
String subPath)
throws NoSuchFilesetException, IOException {
diff --git a/docs/open-api/filesets.yaml b/docs/open-api/filesets.yaml
index 956b9bad63..20ef5d1cf6 100644
--- a/docs/open-api/filesets.yaml
+++ b/docs/open-api/filesets.yaml
@@ -176,7 +176,12 @@ paths:
required: true
schema:
type: string
- description: The sub path to the file or directory
+ description: >-
+ The sub path to the file or directory within the selected storage
location.
+ Leading and trailing whitespace is trimmed. An empty path or `/`
refers to
+ that location, and a leading slash is relative to it. Complete
`..` path
+ segments and NUL characters (U+0000) are not allowed.
+ The normalized path must remain within the selected storage
location.
- name: location_name
in: query
required: false
@@ -187,6 +192,8 @@ paths:
responses:
"200":
$ref: "#/components/responses/FileLocationResponse"
+ "400":
+ $ref: "#/components/responses/InvalidSubPathErrorResponse"
"404":
description: Not Found - The target fileset does not exist
content:
@@ -225,7 +232,13 @@ paths:
schema:
type: string
default: "/"
- description: The sub path within the fileset to list files and
directories from
+ description: >-
+ The sub path within the selected storage location to list files
and directories
+ from. Leading and trailing whitespace is trimmed. An empty path or
`/` refers
+ to that location, and a leading slash is relative to it. Complete
`..` path
+ segments and NUL characters (U+0000) are not allowed.
+ The normalized path must remain within the selected storage
location.
+ The path must exist; otherwise, this operation returns HTTP 400.
- name: location_name
in: query
required: false
@@ -235,8 +248,10 @@ paths:
responses:
"200":
$ref: "#/components/responses/FileInfoListResponse"
+ "400":
+ $ref: "#/components/responses/InvalidSubPathErrorResponse"
"404":
- description: Not Found – the target fileset or subPath or
locationName does not exist
+ description: Not Found – the target fileset or locationName does not
exist
content:
application/vnd.gravitino.v1+json:
schema:
@@ -467,6 +482,18 @@ components:
}
responses:
+ InvalidSubPathErrorResponse:
+ description: >-
+ The sub path does not satisfy the fileset path requirements.
+ content:
+ application/vnd.gravitino.v1+json:
+ schema:
+ $ref: "./openapi.yaml#/components/schemas/ErrorModel"
+ example:
+ code: 1001
+ type: IllegalArgumentException
+ message: subPath must not contain parent directory segments
+
FilesetResponse:
description: The response of fileset object
content:
diff --git
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java
index 770274b946..544c02a26f 100644
---
a/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java
+++
b/server/src/test/java/org/apache/gravitino/server/web/rest/TestFilesetOperations.java
@@ -35,9 +35,12 @@ import com.google.common.collect.Maps;
import java.io.IOException;
import java.time.Instant;
import java.util.Map;
+import java.util.stream.Stream;
import javax.servlet.http.HttpServletRequest;
import javax.ws.rs.client.Entity;
import javax.ws.rs.core.Application;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.HttpHeaders;
import javax.ws.rs.core.MediaType;
import javax.ws.rs.core.Response;
import org.apache.commons.lang3.reflect.FieldUtils;
@@ -77,16 +80,22 @@ import org.glassfish.jersey.test.TestProperties;
import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.BeforeAll;
import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.Arguments;
+import org.junit.jupiter.params.provider.MethodSource;
import org.mockito.Mockito;
import org.mockito.stubbing.Answer;
public class TestFilesetOperations extends BaseOperationsTest {
private static class MockServletRequestFactory extends
ServletRequestFactoryBase {
+ @Context private HttpHeaders headers;
+
@Override
public HttpServletRequest get() {
HttpServletRequest request = mock(HttpServletRequest.class);
when(request.getRemoteUser()).thenReturn(null);
- when(request.getHeader(Version.CLIENT_VERSION_HEADER)).thenReturn(null);
+ when(request.getHeader(Version.CLIENT_VERSION_HEADER))
+ .thenReturn(headers.getHeaderString(Version.CLIENT_VERSION_HEADER));
return request;
}
}
@@ -722,6 +731,49 @@ public class TestFilesetOperations extends
BaseOperationsTest {
Assertions.assertNull(CallerContext.CallerContextHolder.get());
}
+ @ParameterizedTest
+ @MethodSource("invalidSubPathRequests")
+ void testInvalidSubPathsReturnBadRequest(String route, String subPath,
String clientVersion)
+ throws IOException {
+ Mockito.clearInvocations(dispatcher);
+ NameIdentifier ident = NameIdentifier.of(metalake, catalog, schema,
"fileset1");
+ String decodedSubPath = "/../outside";
+ IllegalArgumentException exception =
+ new IllegalArgumentException("subPath must not contain parent
directory segments");
+ doThrow(exception).when(dispatcher).getFileLocation(ident, decodedSubPath,
null);
+ doThrow(exception).when(dispatcher).listFiles(ident, null, decodedSubPath);
+
+ try (Response response =
+ target(filesetPath(metalake, catalog, schema) + "fileset1/" + route)
+ .queryParam("sub_path", subPath)
+ .request(MediaType.APPLICATION_JSON_TYPE)
+ .header(Version.CLIENT_VERSION_HEADER, clientVersion)
+ .accept("application/vnd.gravitino.v1+json")
+ .get()) {
+ Assertions.assertEquals(Response.Status.BAD_REQUEST.getStatusCode(),
response.getStatus());
+ ErrorResponse error = response.readEntity(ErrorResponse.class);
+ Assertions.assertEquals(ErrorConstants.ILLEGAL_ARGUMENTS_CODE,
error.getCode());
+ Assertions.assertEquals(IllegalArgumentException.class.getSimpleName(),
error.getType());
+
Assertions.assertTrue(error.getMessage().contains(exception.getMessage()));
+ }
+
+ if (route.equals("location")) {
+ Mockito.verify(dispatcher).getFileLocation(ident, decodedSubPath, null);
+ } else {
+ Mockito.verify(dispatcher).listFiles(ident, null, decodedSubPath);
+ }
+ }
+
+ private static Stream<Arguments> invalidSubPathRequests() {
+ return Stream.of("files", "location")
+ .flatMap(
+ route ->
+ Stream.of(
+ Arguments.of(route, "/../outside", null),
+ Arguments.of(route, "%2F%2e%2E%2Foutside", "1.3.0"),
+ Arguments.of(route, "%252F%252e%252e%252Foutside",
"0.9.1")));
+ }
+
private void assertUpdateFileset(FilesetUpdatesRequest req, Fileset
updatedFileset) {
when(dispatcher.alterFileset(any(),
any(FilesetChange.class))).thenReturn(updatedFileset);