This is an automated email from the ASF dual-hosted git repository.

nsivabalan pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/hudi.git


The following commit(s) were added to refs/heads/master by this push:
     new 3b5bb09b1d36 fix(writer): reject directory-traversal partition paths 
across all writers (#19342)
3b5bb09b1d36 is described below

commit 3b5bb09b1d365f5e31cf30a8dfdd0a48ec96b2b0
Author: Sivabalan Narayanan <[email protected]>
AuthorDate: Mon Oct 5 22:54:44 2026 -0700

    fix(writer): reject directory-traversal partition paths across all writers 
(#19342)
    
    fix(writer): reject directory-traversal partition paths across all writers
    
      A partition-path field value containing ".." segments could escape the
      table base path and write Hudi-managed files into arbitrary directories
      reachable by the writer's credentials. URL-encoding of partition values
      is opt-in and disabled by default, and nothing else validated the value.
    
      PartitionPathEncodeUtils.validateNoPathTraversal rejects whole ".."
      segments regardless of the urlencode setting. It runs in the write
      handles (HoodieWriteHandle, HoodieRowCreateHandle,
      HoodieRowDataCreateHandle), once per partition rather than per record,
      right before the partition directory is created.
    
      Only a whole ".." segment can escape, so 2024-01-01, region=us-west-2
      and hive-style rider=../evil are still accepted. Encoding instead of
      throwing was rejected: escapePathName leaves "." untouched, and encoding
      here would desync the directory from the partition path already recorded
      in the HoodieKey, markers and WriteStatus.
---
 .../apache/hudi/cli/commands/RepairsCommand.java   |  4 +-
 .../java/org/apache/hudi/io/BaseCreateHandle.java  |  2 +-
 .../hudi/io/FileGroupReaderBasedMergeHandle.java   |  2 +-
 .../apache/hudi/io/HoodieAbstractMergeHandle.java  |  2 +-
 .../org/apache/hudi/io/HoodieAppendHandle.java     |  2 +-
 .../io/storage/row/HoodieRowDataCreateHandle.java  |  2 +-
 .../hudi/io/storage/row/HoodieRowCreateHandle.java |  2 +-
 .../hudi/common/model/HoodiePartitionMetadata.java | 12 ++-
 .../hudi/common/util/PartitionPathEncodeUtils.java | 60 +++++++++++++++
 .../common/testutils/HoodieTestDataGenerator.java  |  2 +-
 .../common/util/TestPartitionPathEncodeUtils.java  | 59 ++++++++++++++-
 .../common/model/TestHoodiePartitionMetadata.java  | 88 +++++++++++++++++++++-
 .../hudi/common/testutils/HoodieTestTable.java     |  2 +-
 .../hudi/common/util/TestTablePathUtils.java       |  4 +-
 .../hudi/hadoop/testutils/InputFormatTestUtil.java |  2 +-
 .../AlterHoodieTableAddPartitionCommand.scala      |  8 +-
 .../RepairAddpartitionmetaProcedure.scala          |  2 +-
 .../RepairMigratePartitionMetaProcedure.scala      |  2 +-
 18 files changed, 234 insertions(+), 23 deletions(-)

diff --git 
a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java 
b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java
index 9d6e1872aa68..a4698a083a61 100644
--- a/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java
+++ b/hudi-cli/src/main/java/org/apache/hudi/cli/commands/RepairsCommand.java
@@ -142,7 +142,7 @@ public class RepairsCommand {
           HoodiePartitionMetadata partitionMetadata =
               new HoodiePartitionMetadata(HoodieCLI.storage, latestCommit, 
basePath, partitionPath,
                   client.getTableConfig().getPartitionMetafileFormat());
-          partitionMetadata.trySave();
+          partitionMetadata.trySave(partition);
           row[2] = "Repaired";
         }
       }
@@ -305,7 +305,7 @@ public class RepairsCommand {
           HoodiePartitionMetadata partitionMetadata =
               new HoodiePartitionMetadata(HoodieCLI.storage, latestCommit, 
basePath, partition,
                   Option.of(client.getTableConfig().getBaseFileFormat()));
-          partitionMetadata.trySave();
+          partitionMetadata.trySave(partitionPath);
         }
 
         // delete it, in case we failed midway last time.
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
index 1ce8807e425b..1e5c5b83c05d 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/BaseCreateHandle.java
@@ -86,7 +86,7 @@ public abstract class BaseCreateHandle<T, I, K, O> extends 
HoodieWriteHandle<T,
         new StoragePath(config.getBasePath()),
         FSUtils.constructAbsolutePath(config.getBasePath(), partitionPath),
         hoodieTable.getPartitionMetafileFormat());
-    partitionMetadata.trySave();
+    partitionMetadata.trySave(partitionPath);
     createMarkerFile(partitionPath,
         FSUtils.makeBaseFileName(this.instantTime, this.writeToken, 
this.fileId, hoodieTable.getBaseFileExtension()));
   }
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java
index c7abeda7eeed..4b5a227d4ff9 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/FileGroupReaderBasedMergeHandle.java
@@ -234,7 +234,7 @@ public class FileGroupReaderBasedMergeHandle<T, I, K, O> 
extends HoodieWriteMerg
           new StoragePath(config.getBasePath()),
           FSUtils.constructAbsolutePath(config.getBasePath(), partitionPath),
           hoodieTable.getPartitionMetafileFormat());
-      partitionMetadata.trySave();
+      partitionMetadata.trySave(partitionPath);
 
       String oldFileName = latestValidFilePath.isPresent() ? 
latestValidFilePath.get() : null;
       String newFileName = createNewFileName(oldFileName);
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAbstractMergeHandle.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAbstractMergeHandle.java
index 36c4525a7cf2..93b2ae6277c1 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAbstractMergeHandle.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAbstractMergeHandle.java
@@ -117,7 +117,7 @@ public abstract class HoodieAbstractMergeHandle<T, I, K, O> 
extends HoodieWriteH
         new StoragePath(config.getBasePath()),
         FSUtils.constructAbsolutePath(config.getBasePath(), partitionPath),
         hoodieTable.getPartitionMetafileFormat());
-    partitionMetadata.trySave();
+    partitionMetadata.trySave(partitionPath);
 
     String newFileName = createNewFileName(latestValidFilePath);
     oldFilePath = makeNewFilePath(partitionPath, latestValidFilePath);
diff --git 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java
 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java
index 56a48a82dd80..00e62f361575 100644
--- 
a/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java
+++ 
b/hudi-client/hudi-client-common/src/main/java/org/apache/hudi/io/HoodieAppendHandle.java
@@ -272,7 +272,7 @@ public abstract class HoodieAppendHandle<T, I, K, O> 
extends HoodieWriteHandle<T
           new StoragePath(config.getBasePath()),
           FSUtils.constructAbsolutePath(config.getBasePath(), partitionPath),
           hoodieTable.getPartitionMetafileFormat());
-      partitionMetadata.trySave();
+      partitionMetadata.trySave(partitionPath);
 
       String logInstantTime = 
config.getWriteVersion().greaterThanOrEquals(HoodieTableVersion.EIGHT)
           ? getInstantTimeForLogFile(record) : deltaWriteStat.getPrevCommit();
diff --git 
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java
 
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java
index f5cd0c739930..9fd73e261545 100644
--- 
a/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java
+++ 
b/hudi-client/hudi-flink-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowDataCreateHandle.java
@@ -127,7 +127,7 @@ public class HoodieRowDataCreateHandle implements 
Serializable {
               new StoragePath(writeConfig.getBasePath()),
               FSUtils.constructAbsolutePath(writeConfig.getBasePath(), 
partitionPath),
               table.getPartitionMetafileFormat());
-      partitionMetadata.trySave();
+      partitionMetadata.trySave(partitionPath);
       createMarkerFile(partitionPath, 
FSUtils.makeBaseFileName(this.instantTime, getWriteToken(), this.fileId, 
table.getBaseFileExtension()));
       this.fileWriter = createNewFileWriter(path, table, writeConfig, 
this.instantTime);
     } catch (IOException e) {
diff --git 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java
 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java
index d5d573d4d89a..33a615cb089b 100644
--- 
a/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java
+++ 
b/hudi-client/hudi-spark-client/src/main/java/org/apache/hudi/io/storage/row/HoodieRowCreateHandle.java
@@ -140,7 +140,7 @@ public class HoodieRowCreateHandle implements Serializable {
               new StoragePath(writeConfig.getBasePath()),
               FSUtils.constructAbsolutePath(writeConfig.getBasePath(), 
partitionPath),
               table.getPartitionMetafileFormat());
-      partitionMetadata.trySave();
+      partitionMetadata.trySave(partitionPath);
 
       createMarkerFile(partitionPath, fileName, instantTime, table, 
writeConfig);
 
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodiePartitionMetadata.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodiePartitionMetadata.java
index be4fbabb95f9..ec18bffe065f 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/model/HoodiePartitionMetadata.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/model/HoodiePartitionMetadata.java
@@ -20,6 +20,7 @@ package org.apache.hudi.common.model;
 
 import org.apache.hudi.common.util.FileFormatUtils;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.PartitionPathEncodeUtils;
 import org.apache.hudi.common.util.RetryHelper;
 import org.apache.hudi.common.util.StringUtils;
 import org.apache.hudi.core.io.storage.HoodieIOFactory;
@@ -42,6 +43,7 @@ import java.util.stream.Collectors;
 import java.util.stream.Stream;
 
 import static 
org.apache.hudi.common.table.HoodieTableMetaClient.COMMIT_TIME_KEY;
+import static org.apache.hudi.common.util.ValidationUtils.checkArgument;
 
 /**
  * The metadata that goes into the meta file in each partition.
@@ -95,9 +97,14 @@ public class HoodiePartitionMetadata {
   }
 
   /**
-   * Write the metadata safely into partition atomically.
+   * Write missing partition metadata safely and atomically, validating the 
original relative partition path
+   * immediately before creating the file. Existing metadata files are not 
revalidated.
+   *
+   * @param relativePartitionPath partition path before StoragePath 
normalization, including any key-generator URL encoding;
+   *                              use an empty string for non-partitioned 
tables
    */
-  public void trySave() throws HoodieIOException {
+  public void trySave(String relativePartitionPath) throws HoodieIOException {
+    checkArgument(relativePartitionPath != null, "Relative partition path must 
not be null");
     StoragePath metaPath = new StoragePath(
         partitionPath, HOODIE_PARTITION_METAFILE_PREFIX + 
getMetafileExtension());
 
@@ -106,6 +113,7 @@ public class HoodiePartitionMetadata {
     RetryHelper<Void, HoodieIOException>  retryHelper = new RetryHelper(1000, 
3, 1000, HoodieIOException.class.getName())
         .tryWith(() -> {
           if (!storage.exists(metaPath)) {
+            
PartitionPathEncodeUtils.validateNoPathTraversal(relativePartitionPath);
             if (format.isPresent()) {
               writeMetafileInFormat(metaPath, format.get());
             } else {
diff --git 
a/hudi-common/src/main/java/org/apache/hudi/common/util/PartitionPathEncodeUtils.java
 
b/hudi-common/src/main/java/org/apache/hudi/common/util/PartitionPathEncodeUtils.java
index a1c45d316740..6990a526d77b 100644
--- 
a/hudi-common/src/main/java/org/apache/hudi/common/util/PartitionPathEncodeUtils.java
+++ 
b/hudi-common/src/main/java/org/apache/hudi/common/util/PartitionPathEncodeUtils.java
@@ -17,6 +17,8 @@
 
 package org.apache.hudi.common.util;
 
+import org.apache.hudi.exception.HoodieKeyException;
+
 import java.util.BitSet;
 import java.util.function.Function;
 
@@ -174,4 +176,62 @@ public class PartitionPathEncodeUtils {
       return escapePathName(value);
     }
   }
+
+  /**
+   * Validates that the given (relative) partition path does not contain a 
directory-traversal
+   * segment, throwing {@link HoodieKeyException} if it does. This is enforced 
regardless of the
+   * {@code hoodie.datasource.write.partitionpath.urlencode} setting, since 
url-encoding is opt-in
+   * (disabled by default) and does not escape {@code '.'}, so it never 
neutralizes {@code ".."}.
+   *
+   * @param partitionPath the relative partition path (or a single partition 
field value).
+   * @return the same {@code partitionPath} if it is safe.
+   * @throws HoodieKeyException if the partition path contains a {@code ".."} 
traversal segment.
+   */
+  public static String validateNoPathTraversal(String partitionPath) {
+    if (hasPathTraversal(partitionPath)) {
+      throw new HoodieKeyException("Invalid partition path \"" + partitionPath 
+ "\": partition paths "
+          + "must not contain \"..\" path-traversal segments, which could let 
a record write Hudi files "
+          + "outside the table base path. This is most often caused by an 
unsanitized data field being "
+          + "used as the partition path; sanitize or remap the offending value 
in the upstream source "
+          + "or via a transformer before ingesting it.");
+    }
+    return partitionPath;
+  }
+
+  /**
+   * Returns {@code true} if the given (relative) partition path contains a 
directory-traversal
+   * segment (a path segment equal to {@code ".."}). Such a partition path, 
once resolved against
+   * the table base path, can escape the base path and write Hudi-managed 
files into arbitrary
+   * directories reachable by the writer's credentials.
+   *
+   * <p>The check is intentionally value-content only: it tolerates {@code 
'.'} inside a segment
+   * (e.g. date partitions like {@code 2024.01.01}) and only rejects the 
standalone {@code ".."}
+   * segment. Both forward slash {@code '/'} and the platform-independent 
literal are treated as
+   * separators, since a partition path is always stored using forward slashes.
+   *
+   * @param partitionPath the relative partition path (or a single partition 
field value).
+   * @return {@code true} if a {@code ".."} traversal segment is present, 
{@code false} otherwise.
+   */
+  public static boolean hasPathTraversal(String partitionPath) {
+    if (partitionPath == null || partitionPath.isEmpty()) {
+      return false;
+    }
+    // Normalize backslashes to forward slashes so a "..\.." style value 
cannot slip through on
+    // any platform. Partition paths are always persisted using forward 
slashes.
+    String normalized = partitionPath.replace('\\', '/');
+    int start = 0;
+    int len = normalized.length();
+    while (start <= len) {
+      int end = normalized.indexOf('/', start);
+      if (end < 0) {
+        end = len;
+      }
+      // A segment of exactly ".." is a traversal segment.
+      if (end - start == 2 && normalized.charAt(start) == '.' && 
normalized.charAt(start + 1) == '.') {
+        return true;
+      }
+      start = end + 1;
+    }
+    return false;
+  }
 }
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java
index ccaaa1b5fa81..67bdd8eee5e4 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestDataGenerator.java
@@ -368,7 +368,7 @@ public class HoodieTestDataGenerator implements 
AutoCloseable {
                                      String basePath) {
     for (String partitionPath : partitionPaths) {
       new HoodiePartitionMetadata(storage, "000", new StoragePath(basePath),
-          new StoragePath(basePath, partitionPath), Option.empty()).trySave();
+          new StoragePath(basePath, partitionPath), 
Option.empty()).trySave(partitionPath);
     }
   }
 
diff --git 
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestPartitionPathEncodeUtils.java
 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestPartitionPathEncodeUtils.java
index 84e754a475a7..16621e1643d9 100644
--- 
a/hudi-common/src/test/java/org/apache/hudi/common/util/TestPartitionPathEncodeUtils.java
+++ 
b/hudi-common/src/test/java/org/apache/hudi/common/util/TestPartitionPathEncodeUtils.java
@@ -17,15 +17,22 @@
 
 package org.apache.hudi.common.util;
 
+import org.apache.hudi.exception.HoodieKeyException;
+
 import org.junit.jupiter.api.Test;
+import org.junit.jupiter.params.ParameterizedTest;
+import org.junit.jupiter.params.provider.ValueSource;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertNull;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.junit.jupiter.api.Assertions.assertTrue;
 
 /**
  * Tests for {@link PartitionPathEncodeUtils}.
  */
-public class TestPartitionPathEncodeUtils {
+class TestPartitionPathEncodeUtils {
 
   @Test
   public void testEscapeFileNameWithDotInColumnName() {
@@ -80,4 +87,54 @@ public class TestPartitionPathEncodeUtils {
     assertEquals("date%3D2024.01.01", escaped);
     assertEquals(original, PartitionPathEncodeUtils.unescapePathName(escaped));
   }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "..",
+      "../foo",
+      "foo/..",
+      "foo/../bar",
+      "../../../../tmp/evil",
+      "a/../../b",
+      "..\\foo",
+      "foo\\..\\bar"
+  })
+  void detectsPathTraversal(String partitionPath) {
+    assertTrue(PartitionPathEncodeUtils.hasPathTraversal(partitionPath),
+        "Expected traversal to be detected for: " + partitionPath);
+    assertThrows(HoodieKeyException.class,
+        () -> PartitionPathEncodeUtils.validateNoPathTraversal(partitionPath));
+  }
+
+  @ParameterizedTest
+  @ValueSource(strings = {
+      "2024/01/01",
+      "2024-01-01",
+      "region=us-west-2",
+      "year=2024/month=01",
+      "rider=..",
+      "rider=../evil",
+      "..foo",
+      "foo..",
+      "foo..bar",
+      "a.b.c",
+      "...",
+      "....",
+      "a/.hidden/b",
+      "."
+  })
+  void allowsLegitimatePartitionPaths(String partitionPath) {
+    assertFalse(PartitionPathEncodeUtils.hasPathTraversal(partitionPath),
+        "Expected no traversal for: " + partitionPath);
+    // validate should return the same value unchanged.
+    assertEquals(partitionPath, 
PartitionPathEncodeUtils.validateNoPathTraversal(partitionPath));
+  }
+
+  @Test
+  void nullAndEmptyAreSafe() {
+    assertFalse(PartitionPathEncodeUtils.hasPathTraversal(null));
+    assertFalse(PartitionPathEncodeUtils.hasPathTraversal(""));
+    assertEquals(null, PartitionPathEncodeUtils.validateNoPathTraversal(null));
+    assertEquals("", PartitionPathEncodeUtils.validateNoPathTraversal(""));
+  }
 }
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/model/TestHoodiePartitionMetadata.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/model/TestHoodiePartitionMetadata.java
index ef01aa7deedf..7604670b588c 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/model/TestHoodiePartitionMetadata.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/model/TestHoodiePartitionMetadata.java
@@ -20,7 +20,9 @@ package org.apache.hudi.common.model;
 
 import org.apache.hudi.common.testutils.HoodieCommonTestHarness;
 import org.apache.hudi.common.util.Option;
+import org.apache.hudi.common.util.PartitionPathEncodeUtils;
 import org.apache.hudi.exception.HoodieException;
+import org.apache.hudi.exception.HoodieKeyException;
 import org.apache.hudi.storage.HoodieStorage;
 import org.apache.hudi.storage.StoragePath;
 
@@ -36,6 +38,7 @@ import java.util.Arrays;
 import java.util.stream.Stream;
 
 import static org.junit.jupiter.api.Assertions.assertEquals;
+import static org.junit.jupiter.api.Assertions.assertFalse;
 import static org.junit.jupiter.api.Assertions.assertThrows;
 import static org.junit.jupiter.api.Assertions.assertTrue;
 
@@ -70,14 +73,14 @@ public class TestHoodiePartitionMetadata extends 
HoodieCommonTestHarness {
   @MethodSource("formatProviderFn")
   public void testTextFormatMetaFile(Option<HoodieFileFormat> format) throws 
IOException {
     // given
-    final StoragePath partitionPath = new StoragePath(basePath, "a/b/"
-        + format.map(Enum::name).orElse("text"));
+    String relativePartitionPath = "a/b/" + 
format.map(Enum::name).orElse("text");
+    final StoragePath partitionPath = new StoragePath(basePath, 
relativePartitionPath);
     storage.createDirectory(partitionPath);
     final String commitTime = "000000000001";
     HoodiePartitionMetadata writtenMetadata = new HoodiePartitionMetadata(
         metaClient.getStorage(), commitTime, new StoragePath(basePath), 
partitionPath,
         format);
-    writtenMetadata.trySave();
+    writtenMetadata.trySave(relativePartitionPath);
 
     // when
     HoodiePartitionMetadata readMetadata = new HoodiePartitionMetadata(
@@ -89,6 +92,85 @@ public class TestHoodiePartitionMetadata extends 
HoodieCommonTestHarness {
     assertEquals(3, readMetadata.getPartitionDepth());
   }
 
+  @ParameterizedTest
+  @MethodSource("formatProviderFn")
+  void testRejectsTraversalBeforeCreatingMetadata(Option<HoodieFileFormat> 
format) throws IOException {
+    StoragePath tablePath = new StoragePath(basePath, "table");
+    for (String relativePartitionPath : Arrays.asList("..", "../outside", 
"a/../b", "..\\outside")) {
+      StoragePath partitionPath = new StoragePath(tablePath, 
relativePartitionPath);
+      storage.createDirectory(partitionPath);
+      HoodiePartitionMetadata metadata = new HoodiePartitionMetadata(
+          storage, "0001", tablePath, partitionPath, format);
+
+      assertThrows(HoodieKeyException.class, () -> 
metadata.trySave(relativePartitionPath));
+      assertFalse(HoodiePartitionMetadata.hasPartitionMetadata(storage, 
partitionPath));
+    }
+  }
+
+  @ParameterizedTest
+  @MethodSource("formatProviderFn")
+  void testValidatesOriginalPathBeforeNormalization(Option<HoodieFileFormat> 
format) throws IOException {
+    String relativePartitionPath = "a/../b";
+    StoragePath partitionPath = new StoragePath(basePath, 
relativePartitionPath);
+    assertEquals(new StoragePath(basePath, "b"), partitionPath);
+    storage.createDirectory(partitionPath);
+    HoodiePartitionMetadata metadata = new HoodiePartitionMetadata(
+        storage, "0001", new StoragePath(basePath), partitionPath, format);
+    assertThrows(HoodieKeyException.class, () -> 
metadata.trySave(relativePartitionPath));
+    assertTrue(storage.listDirectEntries(partitionPath).isEmpty());
+  }
+
+  @ParameterizedTest
+  @MethodSource("formatProviderFn")
+  void testExistingMetadataSkipsTraversalValidation(Option<HoodieFileFormat> 
format) throws IOException {
+    StoragePath partitionPath = new StoragePath(basePath, "b");
+    storage.createDirectory(partitionPath);
+    HoodiePartitionMetadata original = new HoodiePartitionMetadata(
+        storage, "0001", new StoragePath(basePath), partitionPath, format);
+    original.trySave("b");
+
+    HoodiePartitionMetadata subsequent = new HoodiePartitionMetadata(
+        storage, "0002", new StoragePath(basePath), partitionPath, format);
+    subsequent.trySave("a/../b");
+    HoodiePartitionMetadata saved = new HoodiePartitionMetadata(storage, 
partitionPath);
+    assertEquals(Option.of("0001"), saved.readPartitionCreatedCommitTime());
+  }
+
+  @ParameterizedTest
+  @MethodSource("formatProviderFn")
+  void testExistingEncodingIsAccepted(Option<HoodieFileFormat> format) throws 
IOException {
+    String partitionValue = "../a/./b";
+    String encodedPath = 
PartitionPathEncodeUtils.escapePathName(partitionValue);
+    StoragePath partitionPath = new StoragePath(basePath, encodedPath);
+    storage.createDirectory(partitionPath);
+    HoodiePartitionMetadata metadata = new HoodiePartitionMetadata(
+        storage, "0001", new StoragePath(basePath), partitionPath, format);
+    metadata.trySave(encodedPath);
+    assertTrue(HoodiePartitionMetadata.hasPartitionMetadata(storage, 
partitionPath));
+    assertEquals(new StoragePath(basePath), partitionPath.getParent());
+    assertEquals(partitionValue, 
PartitionPathEncodeUtils.unescapePathName(encodedPath));
+  }
+
+  @ParameterizedTest
+  @MethodSource("formatProviderFn")
+  void testEmptyRelativePartitionPath(Option<HoodieFileFormat> format) {
+    StoragePath tablePath = new StoragePath(basePath);
+    HoodiePartitionMetadata metadata = new HoodiePartitionMetadata(
+        storage, "0001", tablePath, tablePath, format);
+    metadata.trySave("");
+    assertTrue(HoodiePartitionMetadata.hasPartitionMetadata(storage, 
tablePath));
+    assertEquals(0, metadata.getPartitionDepth());
+  }
+
+  @Test
+  void testRejectsNullRelativePartitionPath() {
+    StoragePath tablePath = new StoragePath(basePath);
+    HoodiePartitionMetadata metadata = new HoodiePartitionMetadata(
+        storage, "0001", tablePath, tablePath, Option.empty());
+    assertThrows(IllegalArgumentException.class, () -> metadata.trySave(null));
+    assertFalse(HoodiePartitionMetadata.hasPartitionMetadata(storage, 
tablePath));
+  }
+
   @Test
   public void testErrorIfAbsent() throws IOException {
     final StoragePath partitionPath = new StoragePath(basePath, 
"a/b/not-a-partition");
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java
index f731a3ff0bc0..1fe77dc7d7ad 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/testutils/HoodieTestTable.java
@@ -732,7 +732,7 @@ public class HoodieTestTable implements AutoCloseable {
       StoragePath partitionPathObj = FSUtils.getAbsolutePartitionPath(new 
StoragePath(basePath), partitionPath);
       HoodiePartitionMetadata partitionMetadata = new 
HoodiePartitionMetadata(storage,
           currentInstantTime, new StoragePath(basePath), partitionPathObj, 
Option.empty());
-      partitionMetadata.trySave();
+      partitionMetadata.trySave(partitionPath);
     }
     return this;
   }
diff --git 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestTablePathUtils.java
 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestTablePathUtils.java
index 048826e30eb4..896b520f7040 100644
--- 
a/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestTablePathUtils.java
+++ 
b/hudi-hadoop-common/src/test/java/org/apache/hudi/common/util/TestTablePathUtils.java
@@ -81,11 +81,11 @@ public final class TestTablePathUtils {
     HoodiePartitionMetadata partitionMetadata1 = new HoodiePartitionMetadata(
         storage, Instant.now().toString(), tablePath,
         partitionPath1, partitionMetafileFormat);
-    partitionMetadata1.trySave();
+    partitionMetadata1.trySave("key1=abc/key2=def");
     HoodiePartitionMetadata partitionMetadata2 = new HoodiePartitionMetadata(
         storage, Instant.now().toString(), tablePath,
         partitionPath2, partitionMetafileFormat);
-    partitionMetadata2.trySave();
+    partitionMetadata2.trySave("key1=xyz/key2=def");
 
     // Create files
     URI filePathURI1 =
diff --git 
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java
 
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java
index fb40535a6275..88e993520449 100644
--- 
a/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java
+++ 
b/hudi-hadoop-mr/src/test/java/org/apache/hudi/hadoop/testutils/InputFormatTestUtil.java
@@ -547,7 +547,7 @@ public class InputFormatTestUtil {
               new StoragePath(partitionPath.toAbsolutePath().toString()),
               Option.of(HoodieFileFormat.PARQUET));
 
-      partitionMetadata.trySave();
+      
partitionMetadata.trySave(basePath.toAbsolutePath().relativize(partitionPath.toAbsolutePath()).toString());
     }
   }
 
diff --git 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/AlterHoodieTableAddPartitionCommand.scala
 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/AlterHoodieTableAddPartitionCommand.scala
index f56f73504dfa..4a4793555bc5 100644
--- 
a/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/AlterHoodieTableAddPartitionCommand.scala
+++ 
b/hudi-spark-datasource/hudi-spark-common/src/main/scala/org/apache/spark/sql/hudi/command/AlterHoodieTableAddPartitionCommand.scala
@@ -73,10 +73,14 @@ case class AlterHoodieTableAddPartitionCommand(
           throw new HoodieAnalysisException(s"Partition metadata already 
exists for path: $fullPartitionPath")
         }
         None
-      } else Some(new HoodiePartitionMetadata(storage, 
HoodieTimeline.INIT_INSTANT_TS, basePath, fullPartitionPath, format))
+      } else {
+        Some((new HoodiePartitionMetadata(storage, 
HoodieTimeline.INIT_INSTANT_TS, basePath, fullPartitionPath, format), 
partitionPath))
+      }
       (metadata, CatalogTablePartition(spec, table.storage.copy(locationUri = 
Some(fullPartitionPath.toUri))))
     }.unzip
-    partitionMetadata.flatten.foreach(_.trySave)
+    partitionMetadata.flatten.foreach { case (metadata, relativePartitionPath) 
=>
+      metadata.trySave(relativePartitionPath)
+    }
 
     // Sync new partitions in batch, enable ignoreIfExists to be silent for 
existing partitions.
     val batchSize = 
sparkSession.sparkContext.conf.getInt("spark.sql.addPartitionInBatch.size", 100)
diff --git 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairAddpartitionmetaProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairAddpartitionmetaProcedure.scala
index 90c145220dcb..6a90632827a5 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairAddpartitionmetaProcedure.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairAddpartitionmetaProcedure.scala
@@ -69,7 +69,7 @@ class RepairAddpartitionmetaProcedure extends BaseProcedure 
with ProcedureBuilde
         if (!dryRun) {
           val partitionMetadata: HoodiePartitionMetadata = new 
HoodiePartitionMetadata(
             metaClient.getStorage, latestCommit, basePath, partitionPath, 
metaClient.getTableConfig.getPartitionMetafileFormat)
-          partitionMetadata.trySave()
+          partitionMetadata.trySave(partition)
           action = "Repaired"
         }
       }
diff --git 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairMigratePartitionMetaProcedure.scala
 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairMigratePartitionMetaProcedure.scala
index 1f7ebbfde237..57a172f9722e 100644
--- 
a/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairMigratePartitionMetaProcedure.scala
+++ 
b/hudi-spark-datasource/hudi-spark/src/main/scala/org/apache/spark/sql/hudi/command/procedures/RepairMigratePartitionMetaProcedure.scala
@@ -80,7 +80,7 @@ class RepairMigratePartitionMetaProcedure extends 
BaseProcedure with ProcedureBu
           val partitionMetadata: HoodiePartitionMetadata = new 
HoodiePartitionMetadata(
             metaClient.getStorage, latestCommit,
             basePath, partition, 
Option.of(getWriteConfig(basePath.toString).getBaseFileFormat))
-          partitionMetadata.trySave()
+          partitionMetadata.trySave(partitionPath)
         }
         // delete it, in case we failed midway last time.
         textFormatFile.ifPresent(

Reply via email to