fix(catalog): validate identifier names used to build catalog paths (#264)
diff --git a/include/paimon/catalog/catalog.h b/include/paimon/catalog/catalog.h
index 9213bde..1162521 100644
--- a/include/paimon/catalog/catalog.h
+++ b/include/paimon/catalog/catalog.h
@@ -162,19 +162,20 @@
/// Returns the expected location of a specified database.
///
- /// @note This does not check whether the database actually exists.
- ///
/// @param db_name The name of the database to get the location for.
- /// @return A string representing the expected location of the database.
- virtual std::string GetDatabaseLocation(const std::string& db_name) const = 0;
+ /// @return A result containing the expected location of the database, or an error status on
+ /// failure. An implementation that builds the location from the warehouse path, such as the
+ /// file system catalog, answers without checking whether the database exists. One that resolves
+ /// the location on a server, such as the REST catalog, propagates the server's error and so
+ /// fails for a database that does not exist.
+ virtual Result<std::string> GetDatabaseLocation(const std::string& db_name) const = 0;
/// Returns the expected location of a specified table.
///
- /// @note This does not check whether the table actually exists.
- ///
/// @param identifier The table identifier containing database and table name.
/// @return A result containing the expected location of the table, or an error status on
- /// failure.
+ /// failure. Whether a missing table is an error depends on the implementation, in the same way
+ /// as for `GetDatabaseLocation`.
virtual Result<std::string> GetTableLocation(const Identifier& identifier) const = 0;
/// Returns the root path of the catalog.
diff --git a/src/paimon/common/utils/path_util.cpp b/src/paimon/common/utils/path_util.cpp
index 143049e..628cc71 100644
--- a/src/paimon/common/utils/path_util.cpp
+++ b/src/paimon/common/utils/path_util.cpp
@@ -21,6 +21,8 @@
#include <unistd.h>
+#include <algorithm>
+#include <cctype>
#include <cerrno>
#include <cstddef>
#include <cstdint>
@@ -34,6 +36,41 @@
#include "paimon/status.h"
namespace paimon {
+namespace {
+
+/// Escapes the control characters of `name`, so that a rejected name cannot inject a line into
+/// the log the error is written to nor truncate the C string it is copied into. Bytes of a
+/// multi-byte sequence are left alone, since none of them is a control character.
+std::string EscapeControlCharacters(const std::string& name) {
+ std::string escaped;
+ escaped.reserve(name.size());
+ for (char c : name) {
+ switch (c) {
+ case '\\':
+ escaped += "\\\\";
+ break;
+ case '\n':
+ escaped += "\\n";
+ break;
+ case '\r':
+ escaped += "\\r";
+ break;
+ case '\t':
+ escaped += "\\t";
+ break;
+ default:
+ if (std::iscntrl(static_cast<unsigned char>(c)) != 0) {
+ escaped += fmt::format("\\x{{{:02x}}}", static_cast<unsigned char>(c));
+ } else {
+ escaped += c;
+ }
+ }
+ }
+ return escaped;
+}
+
+} // namespace
+
std::string Path::ToString() const {
std::string ret;
if (!scheme.empty()) {
@@ -169,4 +206,24 @@
return JoinPath(GetParentDirPath(path), fmt::format(".{}.{}.tmp", GetName(path), uuid));
}
+Status PathUtil::CheckSinglePathComponent(const std::string& kind, const std::string& name) {
+ const char* reason = nullptr;
+ if (StringUtils::IsNullOrWhitespaceOnly(name)) {
+ reason = "cannot be empty or whitespace";
+ } else if (name == "." || name == "..") {
+ reason = "cannot be '.' or '..'";
+ } else if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) {
+ reason = "cannot contain path separators";
+ } else if (std::any_of(name.begin(), name.end(), [](char c) {
+ return std::iscntrl(static_cast<unsigned char>(c)) != 0;
+ })) {
+ reason = "cannot contain control characters";
+ }
+ if (reason != nullptr) {
+ return Status::Invalid(
+ fmt::format("{} name {}: '{}'", kind, reason, EscapeControlCharacters(name)));
+ }
+ return Status::OK();
+}
+
} // namespace paimon
diff --git a/src/paimon/common/utils/path_util.h b/src/paimon/common/utils/path_util.h
index 9aaff11..7dd81c6 100644
--- a/src/paimon/common/utils/path_util.h
+++ b/src/paimon/common/utils/path_util.h
@@ -52,6 +52,15 @@
static Result<Path> ToPath(const std::string& path) noexcept;
static Result<std::string> NormalizePath(const std::string& path) noexcept;
+ /// Fails when `name` cannot be used as a single path component, which is required to keep a
+ /// path built with `JoinPath` under the directory it is joined to: `name` must not be empty
+ /// or whitespace-only, must not be "." or "..", and must contain neither a path separator
+ /// nor a control character. `kind` names the rejected value in the error message, which
+ /// reads "<kind> name <reason>: '<name>'" and escapes the control characters of `name`.
+ ///
+ /// The check is purely lexical and needs no IO.
+ static Status CheckSinglePathComponent(const std::string& kind, const std::string& name);
+
private:
static std::string NormalizeInnerPath(const std::string& path) noexcept;
};
diff --git a/src/paimon/common/utils/path_util_test.cpp b/src/paimon/common/utils/path_util_test.cpp
index 9ab278e..1f66523 100644
--- a/src/paimon/common/utils/path_util_test.cpp
+++ b/src/paimon/common/utils/path_util_test.cpp
@@ -171,4 +171,43 @@
ASSERT_TRUE(StringUtils::EndsWith(tmp_name, ".tmp"));
}
+TEST(PathUtilsTest, TestCheckSinglePathComponent) {
+ // Names that stay a single path component, including names that merely contain a dot and
+ // names outside ascii.
+ for (const char* name : {"db1", "my.db", "a..b", "a b", "数据", "\u00e9t\u00e9"}) {
+ ASSERT_OK(PathUtil::CheckSinglePathComponent("database", name));
+ }
+
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("database", ""),
+ "database name cannot be empty or whitespace");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("database", " \t\n "),
+ "database name cannot be empty or whitespace");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "."),
+ "table name cannot be '.' or '..'");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", ".."),
+ "table name cannot be '.' or '..'");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "../escaped"),
+ "table name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "back\\slash"),
+ "table name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("branch", "line\nfeed"),
+ "branch name cannot contain control characters");
+ ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("branch", std::string("nul\0byte", 8)),
+ "branch name cannot contain control characters");
+}
+
+TEST(PathUtilsTest, TestCheckSinglePathComponentEscapesRejectedName) {
+ // The rejected name is escaped, so that it can neither add a line to the log the error is
+ // written to nor truncate the C string it is copied into.
+ Status newline = PathUtil::CheckSinglePathComponent("database", "line\nfeed\r\t");
+ ASSERT_FALSE(newline.ok());
+ ASSERT_EQ(newline.ToString().find('\n'), std::string::npos);
+ ASSERT_NE(newline.ToString().find("line\\nfeed\\r\\t"), std::string::npos);
+
+ Status nul = PathUtil::CheckSinglePathComponent("database", std::string("nul\0byte", 8));
+ ASSERT_FALSE(nul.ok());
+ ASSERT_EQ(nul.ToString().find('\0'), std::string::npos);
+ ASSERT_NE(nul.ToString().find("nul\\x{00}byte"), std::string::npos);
+}
+
} // namespace paimon::test
diff --git a/src/paimon/core/catalog/catalog_utils.cpp b/src/paimon/core/catalog/catalog_utils.cpp
index eae23ad..104a71b 100644
--- a/src/paimon/core/catalog/catalog_utils.cpp
+++ b/src/paimon/core/catalog/catalog_utils.cpp
@@ -22,6 +22,8 @@
#include "fmt/format.h"
#include "paimon/catalog/catalog.h"
+#include "paimon/common/utils/path_util.h"
+#include "paimon/core/utils/branch_manager.h"
#include "paimon/result.h"
namespace paimon {
@@ -70,4 +72,26 @@
return Status::OK();
}
+Status CatalogUtils::CheckValidDatabaseName(const std::string& db_name) {
+ return PathUtil::CheckSinglePathComponent("database", db_name);
+}
+
+Status CatalogUtils::CheckValidTableName(const Identifier& identifier) {
+ PAIMON_ASSIGN_OR_RAISE(std::string data_table_name, identifier.GetDataTableName());
+ PAIMON_RETURN_NOT_OK(PathUtil::CheckSinglePathComponent("table", data_table_name));
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> branch, identifier.GetBranchName());
+ if (branch) {
+ // The branch of an identifier selects the same directory as the `branch` option, so both
+ // go through the same check.
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(branch.value()));
+ }
+ PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table,
+ identifier.GetSystemTableName());
+ if (system_table) {
+ PAIMON_RETURN_NOT_OK(
+ PathUtil::CheckSinglePathComponent("system table", system_table.value()));
+ }
+ return Status::OK();
+}
+
} // namespace paimon
diff --git a/src/paimon/core/catalog/catalog_utils.h b/src/paimon/core/catalog/catalog_utils.h
index e92cd84..235ad19 100644
--- a/src/paimon/core/catalog/catalog_utils.h
+++ b/src/paimon/core/catalog/catalog_utils.h
@@ -43,6 +43,14 @@
/// Fails when `identifier` carries a "$branch_" suffix.
static Status CheckNotBranch(const Identifier& identifier, const std::string& action);
+
+ /// Fails when `db_name` cannot be used as a single path component, which is required to
+ /// keep the database path under the warehouse.
+ static Status CheckValidDatabaseName(const std::string& db_name);
+
+ /// Fails when any component parsed out of the identifier's table name (data table name,
+ /// branch name, system table name) cannot be used as a single path component.
+ static Status CheckValidTableName(const Identifier& identifier);
};
} // namespace paimon
diff --git a/src/paimon/core/catalog/file_system_catalog.cpp b/src/paimon/core/catalog/file_system_catalog.cpp
index 85907c1..be5f11b 100644
--- a/src/paimon/core/catalog/file_system_catalog.cpp
+++ b/src/paimon/core/catalog/file_system_catalog.cpp
@@ -87,7 +87,7 @@
fmt::join(options, ", "));
PAIMON_LOG_DEBUG(logger_, "%s", log_msg.c_str());
}
- std::string db_path = NewDatabasePath(warehouse_, db_name);
+ PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, db_name));
PAIMON_RETURN_NOT_OK(fs_->Mkdirs(db_path));
return Status::OK();
}
@@ -96,7 +96,8 @@
if (CatalogUtils::IsSystemDatabase(db_name)) {
return true;
}
- return fs_->Exists(NewDatabasePath(warehouse_, db_name));
+ PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, db_name));
+ return fs_->Exists(db_path);
}
Result<bool> FileSystemCatalog::TableExists(const Identifier& identifier) const {
@@ -104,6 +105,9 @@
if (CatalogUtils::IsSystemDatabase(identifier.GetDatabaseName())) {
return GlobalSystemTableLoader::IsSupported(identifier.GetTableName(), catalog_options_);
}
+ // The branch component is dropped when the data table identifier is rebuilt below, so the
+ // identifier is validated as a whole here.
+ PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
PAIMON_ASSIGN_OR_RAISE(bool is_system_table, identifier.IsSystemTable());
if (is_system_table) {
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table_name,
@@ -122,7 +126,7 @@
return latest_schema != std::nullopt;
}
-std::string FileSystemCatalog::GetDatabaseLocation(const std::string& db_name) const {
+Result<std::string> FileSystemCatalog::GetDatabaseLocation(const std::string& db_name) const {
return NewDatabasePath(warehouse_, db_name);
}
@@ -204,16 +208,19 @@
return IsSpecifiedSystemTable(identifier);
}
-std::string FileSystemCatalog::NewDatabasePath(const std::string& warehouse,
- const std::string& db_name) {
+Result<std::string> FileSystemCatalog::NewDatabasePath(const std::string& warehouse,
+ const std::string& db_name) {
+ PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidDatabaseName(db_name));
return PathUtil::JoinPath(warehouse, db_name + DB_SUFFIX);
}
Result<std::string> FileSystemCatalog::NewDataTablePath(const std::string& warehouse,
const Identifier& identifier) {
+ PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
PAIMON_ASSIGN_OR_RAISE(std::string data_table_name, identifier.GetDataTableName());
- return PathUtil::JoinPath(NewDatabasePath(warehouse, identifier.GetDatabaseName()),
- data_table_name);
+ PAIMON_ASSIGN_OR_RAISE(std::string database_path,
+ NewDatabasePath(warehouse, identifier.GetDatabaseName()));
+ return PathUtil::JoinPath(database_path, data_table_name);
}
Result<std::vector<std::string>> FileSystemCatalog::ListDatabases() const {
@@ -235,7 +242,7 @@
if (CatalogUtils::IsSystemDatabase(db_name)) {
return GlobalSystemTableLoader::GetSupportedTableNames(catalog_options_);
}
- std::string database_path = NewDatabasePath(warehouse_, db_name);
+ PAIMON_ASSIGN_OR_RAISE(std::string database_path, NewDatabasePath(warehouse_, db_name));
std::vector<BasicFileStatus> file_status_list;
PAIMON_RETURN_NOT_OK(fs_->ListDir(database_path, &file_status_list));
std::vector<std::string> table_names;
@@ -284,6 +291,9 @@
system_table->ArrowSchema());
return std::make_shared<SystemTableSchema>(std::move(arrow_schema));
}
+ // The branch component is dropped when the data table identifier is rebuilt below, so the
+ // identifier is validated as a whole here.
+ PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
PAIMON_ASSIGN_OR_RAISE(bool is_system_table, identifier.IsSystemTable());
if (is_system_table) {
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table_name,
@@ -342,7 +352,7 @@
}
}
- std::string db_path = NewDatabasePath(warehouse_, name);
+ PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, name));
if (cascade) {
// List all tables in the database and drop them
@@ -511,6 +521,7 @@
Result<std::vector<SnapshotInfo>> FileSystemCatalog::ListSnapshots(
const Identifier& identifier, const std::string& branch) const {
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(branch));
PAIMON_ASSIGN_OR_RAISE(bool exists, TableExists(identifier));
if (!exists) {
return Status::NotExist(fmt::format("table {} does not exist", identifier.ToString()));
diff --git a/src/paimon/core/catalog/file_system_catalog.h b/src/paimon/core/catalog/file_system_catalog.h
index 3925aff..a373dcf 100644
--- a/src/paimon/core/catalog/file_system_catalog.h
+++ b/src/paimon/core/catalog/file_system_catalog.h
@@ -59,7 +59,7 @@
Result<std::vector<std::string>> ListTables(const std::string& db_name) const override;
Result<bool> DatabaseExists(const std::string& db_name) const override;
Result<bool> TableExists(const Identifier& identifier) const override;
- std::string GetDatabaseLocation(const std::string& db_name) const override;
+ Result<std::string> GetDatabaseLocation(const std::string& db_name) const override;
Result<std::string> GetTableLocation(const Identifier& identifier) const override;
Result<std::shared_ptr<Schema>> LoadTableSchema(const Identifier& identifier) const override;
std::string GetRootPath() const override;
@@ -70,7 +70,12 @@
const std::string& branch) const override;
private:
- static std::string NewDatabasePath(const std::string& warehouse, const std::string& db_name);
+ /// Fails when `db_name` cannot be used as a single path component, so that the returned
+ /// path always stays under `warehouse`.
+ static Result<std::string> NewDatabasePath(const std::string& warehouse,
+ const std::string& db_name);
+ /// Fails when the database name or any component of the table name cannot be used as a
+ /// single path component, so that the returned path always stays under `warehouse`.
static Result<std::string> NewDataTablePath(const std::string& warehouse,
const Identifier& identifier);
static Result<bool> IsSpecifiedSystemTable(const Identifier& identifier);
diff --git a/src/paimon/core/catalog/file_system_catalog_test.cpp b/src/paimon/core/catalog/file_system_catalog_test.cpp
index 9f6a568..dba3e67 100644
--- a/src/paimon/core/catalog/file_system_catalog_test.cpp
+++ b/src/paimon/core/catalog/file_system_catalog_test.cpp
@@ -61,7 +61,8 @@
ASSERT_OK_AND_ASSIGN(std::vector<std::string> db_names, catalog.ListDatabases());
ASSERT_EQ(1, db_names.size());
ASSERT_EQ(db_names[0], "db1");
- ASSERT_EQ(catalog.GetDatabaseLocation("db1"), PathUtil::JoinPath(dir->Str(), "db1.db"));
+ ASSERT_OK_AND_ASSIGN(std::string db_location, catalog.GetDatabaseLocation("db1"));
+ ASSERT_EQ(db_location, PathUtil::JoinPath(dir->Str(), "db1.db"));
}
TEST(FileSystemCatalogTest, TestInvalidCreateDatabase) {
@@ -1270,4 +1271,150 @@
ASSERT_FALSE(external_exists);
}
+TEST(FileSystemCatalogTest, TestRejectInvalidNames) {
+ std::map<std::string, std::string> options;
+ options[Options::FILE_SYSTEM] = "local";
+ options[Options::FILE_FORMAT] = "orc";
+ ASSERT_OK_AND_ASSIGN(auto core_options, CoreOptions::FromMap(options));
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ auto fs = core_options.GetFileSystem();
+ // The warehouse is nested inside the test directory, so that the test can assert the
+ // surrounding directory stays untouched.
+ std::string warehouse = PathUtil::JoinPath(dir->Str(), "warehouse");
+ ASSERT_OK(fs->Mkdirs(warehouse));
+ std::string outer_db_path = PathUtil::JoinPath(dir->Str(), "outside.db");
+ FileSystemCatalog catalog(fs, warehouse, options);
+
+ // A rejected database name must fail without creating anything on disk.
+ ASSERT_NOK_WITH_MSG(catalog.CreateDatabase("../outside", {}, /*ignore_if_exists=*/false),
+ "cannot contain path separators");
+ ASSERT_OK_AND_ASSIGN(bool path_exists, fs->Exists(outer_db_path));
+ ASSERT_FALSE(path_exists);
+
+ // A directory that already exists next to the warehouse must not be deleted either.
+ ASSERT_OK(fs->Mkdirs(outer_db_path));
+ ASSERT_NOK_WITH_MSG(catalog.DropDatabase("../outside", /*ignore_if_not_exists=*/true,
+ /*cascade=*/true),
+ "cannot contain path separators");
+ ASSERT_OK_AND_ASSIGN(path_exists, fs->Exists(outer_db_path));
+ ASSERT_TRUE(path_exists);
+
+ ASSERT_NOK_WITH_MSG(catalog.DatabaseExists("../outside"), "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.ListTables("../outside"), "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.GetDatabaseLocation("../outside"),
+ "cannot contain path separators");
+
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32())};
+ arrow::Schema typed_schema(fields);
+ ASSERT_OK(catalog.CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
+ {
+ ::ArrowSchema schema;
+ ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+ ASSERT_OK(catalog.CreateTable(Identifier("db1", "t"), &schema, {}, {}, options,
+ /*ignore_if_exists=*/false));
+ }
+
+ // All table entries reject invalid names before touching the file system. The schema is
+ // never imported on these paths, so a single exported schema can be reused.
+ ::ArrowSchema schema;
+ ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+ const Identifier rejected_db_table("../outside", "t");
+ const Identifier rejected_table("db1", "../evil");
+ ASSERT_NOK_WITH_MSG(catalog.CreateTable(rejected_db_table, &schema, {}, {}, options, false),
+ "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.CreateTable(rejected_table, &schema, {}, {}, options, false),
+ "cannot contain path separators");
+ ArrowSchemaRelease(&schema);
+
+ ASSERT_NOK_WITH_MSG(catalog.GetTableLocation(rejected_table), "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.GetTable(rejected_table), "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.TableExists(rejected_table), "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.DropTable(rejected_table, /*ignore_if_not_exists=*/true),
+ "cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.RenameTable(Identifier("db1", "t"), rejected_table,
+ /*ignore_if_not_exists=*/false),
+ "cannot contain path separators");
+
+ // The branch component of a table name and the branch argument become path components too.
+ ASSERT_NOK_WITH_MSG(catalog.GetTableLocation(Identifier("db1", "t$branch_../../x")),
+ "branch name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.ListSnapshots(Identifier("db1", "t"), "../../x"),
+ "branch name cannot contain path separators");
+
+ // A system table identifier keeps its own branch component, which the entries resolving the
+ // data table must reject as well.
+ const Identifier rejected_branch_system_table("db1", "t$branch_../../x$snapshots");
+ ASSERT_NOK_WITH_MSG(catalog.TableExists(rejected_branch_system_table),
+ "branch name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.LoadTableSchema(rejected_branch_system_table),
+ "branch name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(catalog.GetTable(rejected_branch_system_table),
+ "branch name cannot contain path separators");
+
+ // The surrounding directory is untouched and the valid table still works.
+ ASSERT_OK_AND_ASSIGN(path_exists, fs->Exists(PathUtil::JoinPath(dir->Str(), "db1.db")));
+ ASSERT_FALSE(path_exists);
+ ASSERT_OK_AND_ASSIGN(bool table_exists, catalog.TableExists(Identifier("db1", "t")));
+ ASSERT_TRUE(table_exists);
+}
+
+TEST(FileSystemCatalogTest, TestIdentifierNameValidationRules) {
+ std::map<std::string, std::string> options;
+ options[Options::FILE_SYSTEM] = "local";
+ options[Options::FILE_FORMAT] = "orc";
+ ASSERT_OK_AND_ASSIGN(auto core_options, CoreOptions::FromMap(options));
+ auto dir = UniqueTestDirectory::Create();
+ ASSERT_TRUE(dir);
+ FileSystemCatalog catalog(core_options.GetFileSystem(), dir->Str(), options);
+ ASSERT_OK(catalog.CreateDatabase("db1", {}, /*ignore_if_exists=*/false));
+
+ arrow::FieldVector fields = {arrow::field("f0", arrow::int32())};
+ arrow::Schema typed_schema(fields);
+ struct InvalidName {
+ std::string name;
+ std::string db_error;
+ // An empty table name is already rejected by the identifier itself.
+ std::string table_error;
+ };
+ const std::vector<InvalidName> invalid_names = {
+ {"", "cannot be empty or whitespace", "Invalid table name"},
+ {" ", "cannot be empty or whitespace", "cannot be empty or whitespace"},
+ {".", "cannot be '.' or '..'", "cannot be '.' or '..'"},
+ {"..", "cannot be '.' or '..'", "cannot be '.' or '..'"},
+ {"../escaped", "cannot contain path separators", "cannot contain path separators"},
+ {"nested/name", "cannot contain path separators", "cannot contain path separators"},
+ {"back\\slash", "cannot contain path separators", "cannot contain path separators"},
+ {"line\nfeed", "cannot contain control characters", "cannot contain control characters"},
+ {std::string("nul\0byte", 8), "cannot contain control characters",
+ "cannot contain control characters"},
+ };
+ ::ArrowSchema schema;
+ ASSERT_TRUE(arrow::ExportSchema(typed_schema, &schema).ok());
+ for (const auto& invalid_name : invalid_names) {
+ ASSERT_NOK_WITH_MSG(catalog.CreateDatabase(invalid_name.name, {},
+ /*ignore_if_exists=*/true),
+ invalid_name.db_error);
+ ASSERT_NOK_WITH_MSG(catalog.CreateTable(Identifier("db1", invalid_name.name), &schema, {},
+ {}, options, /*ignore_if_exists=*/true),
+ invalid_name.table_error);
+ }
+ ArrowSchemaRelease(&schema);
+
+ // Names that merely contain a dot or non-ascii characters stay usable.
+ for (const char* db_name : {"my.db", "a..b", "数据"}) {
+ ASSERT_OK(catalog.CreateDatabase(db_name, {}, /*ignore_if_exists=*/false));
+ ASSERT_OK_AND_ASSIGN(bool db_exists, catalog.DatabaseExists(db_name));
+ ASSERT_TRUE(db_exists);
+ }
+ for (const char* table_name : {"orders", "订单"}) {
+ ::ArrowSchema valid_schema;
+ ASSERT_TRUE(arrow::ExportSchema(typed_schema, &valid_schema).ok());
+ ASSERT_OK(catalog.CreateTable(Identifier("db1", table_name), &valid_schema, {}, {}, options,
+ /*ignore_if_exists=*/false));
+ ASSERT_OK_AND_ASSIGN(bool table_exists, catalog.TableExists(Identifier("db1", table_name)));
+ ASSERT_TRUE(table_exists);
+ }
+}
+
} // namespace paimon::test
diff --git a/src/paimon/core/core_options.cpp b/src/paimon/core/core_options.cpp
index 71ba73d..385b230 100644
--- a/src/paimon/core/core_options.cpp
+++ b/src/paimon/core/core_options.cpp
@@ -799,8 +799,14 @@
parser.Parse<bool>(Options::PREFETCH_IO_METRICS_ENABLED, &prefetch_io_metrics_enabled));
// Parse scan.fallback-branch - fallback branch when partition not found
PAIMON_RETURN_NOT_OK(parser.Parse(Options::SCAN_FALLBACK_BRANCH, &scan_fallback_branch));
+ if (scan_fallback_branch) {
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(scan_fallback_branch.value()));
+ }
// Parse branch - branch name, default "main"
PAIMON_RETURN_NOT_OK(parser.Parse(Options::BRANCH, &branch));
+ // Both branches name a directory under the table root, so they must stay a single path
+ // component.
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(branch));
// Parse scan.tag-name - optional tag name for "from-snapshot" scan mode
PAIMON_RETURN_NOT_OK(parser.Parse(Options::SCAN_TAG_NAME, &scan_tag_name));
return Status::OK();
diff --git a/src/paimon/core/core_options_test.cpp b/src/paimon/core/core_options_test.cpp
index c424b9c..56ab466 100644
--- a/src/paimon/core/core_options_test.cpp
+++ b/src/paimon/core/core_options_test.cpp
@@ -545,6 +545,23 @@
"must not be negative");
}
+TEST(CoreOptionsTest, TestRejectBranchLeavingTableRoot) {
+ // Both branch options name a directory under the table root, so a value that is not a single
+ // path component is rejected before it can be joined into a path.
+ ASSERT_NOK_WITH_MSG(CoreOptions::FromMap({{Options::BRANCH, "rt/../../../../../outside"}}),
+ "branch name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(CoreOptions::FromMap({{Options::BRANCH, ".."}}),
+ "branch name cannot be '.' or '..'");
+ ASSERT_NOK_WITH_MSG(
+ CoreOptions::FromMap({{Options::SCAN_FALLBACK_BRANCH, "rt/../../../../../outside"}}),
+ "branch name cannot contain path separators");
+
+ // An empty branch selects the main branch and stays accepted.
+ ASSERT_OK(CoreOptions::FromMap({{Options::BRANCH, ""}}));
+ ASSERT_OK(CoreOptions::FromMap({{Options::BRANCH, "rt"}}));
+ ASSERT_OK(CoreOptions::FromMap({{Options::SCAN_FALLBACK_BRANCH, "rt"}}));
+}
+
TEST(CoreOptionsTest, TestNestedKeyNullStrategyIsCaseInsensitive) {
const std::vector<std::pair<std::string, CoreOptions::NestedKeyNullStrategy>> cases = {
{"MERGE", CoreOptions::NestedKeyNullStrategy::MERGE},
diff --git a/src/paimon/core/operation/commit/realtime_commit_properties_test.cpp b/src/paimon/core/operation/commit/realtime_commit_properties_test.cpp
index afe529e..ce9f2b5 100644
--- a/src/paimon/core/operation/commit/realtime_commit_properties_test.cpp
+++ b/src/paimon/core/operation/commit/realtime_commit_properties_test.cpp
@@ -185,6 +185,10 @@
ASSERT_EQ("/table/metadata", RealtimeCommitProperties::OffsetsDirectory("/table", "main"));
ASSERT_EQ("/table/branch/branch-dev/metadata",
RealtimeCommitProperties::OffsetsDirectory("/table", "dev"));
+ // A branch that selects the main branch resolves to the main offsets directory, even when the
+ // caller passes the raw option value instead of the normalized one a commit writes with.
+ ASSERT_EQ("/table/metadata", RealtimeCommitProperties::OffsetsDirectory("/table", ""));
+ ASSERT_EQ("/table/metadata", RealtimeCommitProperties::OffsetsDirectory("/table", " "));
}
TEST_F(RealtimeCommitPropertiesTest, ReadOffsetsWithoutProgress) {
diff --git a/src/paimon/core/operation/read_context.cpp b/src/paimon/core/operation/read_context.cpp
index deacfa7..774f2a5 100644
--- a/src/paimon/core/operation/read_context.cpp
+++ b/src/paimon/core/operation/read_context.cpp
@@ -278,6 +278,8 @@
if (impl_->path_.empty()) {
return Status::Invalid("cannot read with empty table path");
}
+ // The branch names a directory under the table path, so it must stay a single path component.
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(impl_->branch_));
if (impl_->enable_prefetch_ && impl_->prefetch_max_parallel_num_ == 0) {
return Status::Invalid("prefetch max parallel num should be greater than 0");
}
diff --git a/src/paimon/core/operation/read_context_test.cpp b/src/paimon/core/operation/read_context_test.cpp
index c686ccc..1174568 100644
--- a/src/paimon/core/operation/read_context_test.cpp
+++ b/src/paimon/core/operation/read_context_test.cpp
@@ -126,6 +126,20 @@
ASSERT_EQ(expected_options, ctx->GetOptions());
}
+TEST(ReadContextTest, TestRejectBranchLeavingTablePath) {
+ // The branch names a directory under the table path, so a value that is not a single path
+ // component is rejected when the context is built.
+ ReadContextBuilder builder("table_root_path");
+ builder.WithBranch("rt/../../../../../outside");
+ ASSERT_NOK_WITH_MSG(builder.Finish(), "branch name cannot contain path separators");
+
+ // An empty branch selects the main branch and stays accepted.
+ ReadContextBuilder main_builder("table_root_path");
+ main_builder.WithBranch("");
+ ASSERT_OK_AND_ASSIGN(auto ctx, main_builder.Finish());
+ ASSERT_EQ("", ctx->GetBranch());
+}
+
TEST(ReadContextTest, TestFileSystemAndSchemeMapConflict) {
ReadContextBuilder builder("table_root_path");
auto fs = std::make_shared<MockFileSystem>();
diff --git a/src/paimon/core/operation/write_context.cpp b/src/paimon/core/operation/write_context.cpp
index 457f8d5..c186a6c 100644
--- a/src/paimon/core/operation/write_context.cpp
+++ b/src/paimon/core/operation/write_context.cpp
@@ -198,6 +198,8 @@
if (impl_->root_path_.empty()) {
return Status::Invalid("root path is empty");
}
+ // The branch names a directory under the root path, so it must stay a single path component.
+ PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(impl_->branch_));
bool enable_multi_thread_spill = impl_->spill_thread_number_ > 0;
if (enable_multi_thread_spill) {
PAIMON_RETURN_NOT_OK_FROM_ARROW(
diff --git a/src/paimon/core/operation/write_context_test.cpp b/src/paimon/core/operation/write_context_test.cpp
index ef2225c..b41d147 100644
--- a/src/paimon/core/operation/write_context_test.cpp
+++ b/src/paimon/core/operation/write_context_test.cpp
@@ -100,6 +100,20 @@
ASSERT_EQ(expected_options, ctx->GetOptions());
}
+TEST(WriteContextTest, TestRejectBranchLeavingRootPath) {
+ // The branch names a directory under the root path, so a value that is not a single path
+ // component is rejected when the context is built.
+ WriteContextBuilder builder("table_root_path", "commit_user_1");
+ builder.WithBranch("rt/../../../../../outside");
+ ASSERT_NOK_WITH_MSG(builder.Finish(), "branch name cannot contain path separators");
+
+ // An empty branch selects the main branch and stays accepted.
+ WriteContextBuilder main_builder("table_root_path", "commit_user_1");
+ main_builder.WithBranch("");
+ ASSERT_OK_AND_ASSIGN(auto ctx, main_builder.Finish());
+ ASSERT_EQ("", ctx->GetBranch());
+}
+
TEST(WriteContextTest, TestSetWriteBufferSpillThreadNumber) {
WriteContextBuilder builder("table_root_path", "commit_user_1");
builder.SetWriteBufferSpillThreadNumber(2);
diff --git a/src/paimon/core/utils/branch_manager.h b/src/paimon/core/utils/branch_manager.h
index 5a4a582..975cec9 100644
--- a/src/paimon/core/utils/branch_manager.h
+++ b/src/paimon/core/utils/branch_manager.h
@@ -24,6 +24,7 @@
#include "paimon/common/utils/path_util.h"
#include "paimon/common/utils/string_utils.h"
#include "paimon/result.h"
+#include "paimon/status.h"
namespace paimon {
class FileSystem;
@@ -44,12 +45,25 @@
return StringUtils::IsNullOrWhitespaceOnly(branch) ? DEFAULT_MAIN_BRANCH : branch;
}
- /// Returns the table root path for the selected branch.
+ /// Fails when `branch` cannot be used as a single path component, which is required to keep
+ /// the branch path under the table root. A branch that `NormalizeBranch` maps to `main`
+ /// names no directory of its own and is therefore accepted.
+ static Status CheckValidBranch(const std::string& branch) {
+ if (StringUtils::IsNullOrWhitespaceOnly(branch)) {
+ return Status::OK();
+ }
+ return PathUtil::CheckSinglePathComponent("branch", branch);
+ }
+
+ /// Returns the table root path for the selected branch. A branch that `NormalizeBranch` maps
+ /// to `main` resolves to the table root, so that a caller passing a raw option value cannot
+ /// end up with a directory of its own.
static std::string BranchPath(const std::string& table_root, const std::string& branch) {
- return IsMainBranch(branch)
- ? table_root
- : PathUtil::JoinPath(table_root,
- "/branch/" + std::string(BRANCH_PREFIX) + branch);
+ const std::string normalized = NormalizeBranch(branch);
+ if (IsMainBranch(normalized)) {
+ return table_root;
+ }
+ return PathUtil::JoinPath(table_root, "/branch/" + std::string(BRANCH_PREFIX) + normalized);
}
/// Returns whether the branch is the default main branch.
diff --git a/src/paimon/core/utils/branch_manager_test.cpp b/src/paimon/core/utils/branch_manager_test.cpp
index e3731fa..74fdd04 100644
--- a/src/paimon/core/utils/branch_manager_test.cpp
+++ b/src/paimon/core/utils/branch_manager_test.cpp
@@ -19,6 +19,7 @@
#include "paimon/core/utils/branch_manager.h"
#include "gtest/gtest.h"
+#include "paimon/testing/utils/testharness.h"
namespace paimon::test {
TEST(BranchManagerTest, TestIsMainBranch) {
@@ -40,5 +41,25 @@
TEST(BranchManagerTest, TestBranchPath) {
ASSERT_EQ(BranchManager::BranchPath("/root", BranchManager::DEFAULT_MAIN_BRANCH), "/root");
ASSERT_EQ(BranchManager::BranchPath("/root", "data"), "/root/branch/branch-data");
+ // A branch `NormalizeBranch` maps to `main` resolves to the table root, so that a raw option
+ // value cannot select a directory the main branch never writes to.
+ ASSERT_EQ(BranchManager::BranchPath("/root", ""), "/root");
+ ASSERT_EQ(BranchManager::BranchPath("/root", " "), "/root");
+}
+
+TEST(BranchManagerTest, TestCheckValidBranch) {
+ ASSERT_OK(BranchManager::CheckValidBranch(BranchManager::DEFAULT_MAIN_BRANCH));
+ ASSERT_OK(BranchManager::CheckValidBranch("data"));
+ ASSERT_OK(BranchManager::CheckValidBranch("d a t a"));
+ // A branch `NormalizeBranch` maps to `main` names no directory of its own.
+ ASSERT_OK(BranchManager::CheckValidBranch(""));
+ ASSERT_OK(BranchManager::CheckValidBranch(" "));
+
+ // A branch that would leave the table root is rejected.
+ ASSERT_NOK_WITH_MSG(BranchManager::CheckValidBranch(".."), "branch name cannot be '.' or '..'");
+ ASSERT_NOK_WITH_MSG(BranchManager::CheckValidBranch("rt/../../../../../outside"),
+ "branch name cannot contain path separators");
+ ASSERT_NOK_WITH_MSG(BranchManager::CheckValidBranch("line\nfeed"),
+ "branch name cannot contain control characters");
}
} // namespace paimon::test
diff --git a/src/paimon/rest/rest_catalog.cpp b/src/paimon/rest/rest_catalog.cpp
index eb86035..a6abf18 100644
--- a/src/paimon/rest/rest_catalog.cpp
+++ b/src/paimon/rest/rest_catalog.cpp
@@ -159,18 +159,13 @@
return status;
}
-std::string RestCatalog::GetDatabaseLocation(const std::string& db_name) const {
+Result<std::string> RestCatalog::GetDatabaseLocation(const std::string& db_name) const {
// The virtual "sys" database has no location and is unknown to the server.
if (CatalogUtils::IsSystemDatabase(db_name)) {
- return "";
+ return std::string();
}
- Result<GetDatabaseResponse> response = api_->GetDatabase(db_name);
- if (!response.ok()) {
- PAIMON_LOG_WARN(logger_, "failed to get location of database %s: %s", db_name.c_str(),
- response.status().ToString().c_str());
- return "";
- }
- return response.value().GetLocation();
+ PAIMON_ASSIGN_OR_RAISE(GetDatabaseResponse response, api_->GetDatabase(db_name));
+ return response.GetLocation();
}
Result<std::vector<std::string>> RestCatalog::ListTables(const std::string& db_name) const {
diff --git a/src/paimon/rest/rest_catalog.h b/src/paimon/rest/rest_catalog.h
index e952263..c0f4a39 100644
--- a/src/paimon/rest/rest_catalog.h
+++ b/src/paimon/rest/rest_catalog.h
@@ -66,7 +66,7 @@
Result<std::vector<std::string>> ListTables(const std::string& db_name) const override;
Result<bool> DatabaseExists(const std::string& db_name) const override;
Result<bool> TableExists(const Identifier& identifier) const override;
- std::string GetDatabaseLocation(const std::string& db_name) const override;
+ Result<std::string> GetDatabaseLocation(const std::string& db_name) const override;
Result<std::string> GetTableLocation(const Identifier& identifier) const override;
Result<std::shared_ptr<Schema>> LoadTableSchema(const Identifier& identifier) const override;
std::string GetRootPath() const override;
diff --git a/src/paimon/rest/rest_catalog_test.cpp b/src/paimon/rest/rest_catalog_test.cpp
index 140f3f4..ed61d85 100644
--- a/src/paimon/rest/rest_catalog_test.cpp
+++ b/src/paimon/rest/rest_catalog_test.cpp
@@ -444,8 +444,14 @@
ASSERT_OK_AND_ASSIGN(exists, catalog->DatabaseExists("db3"));
ASSERT_FALSE(exists);
- ASSERT_EQ("wh1/db1.db", catalog->GetDatabaseLocation("db1"));
- ASSERT_EQ("", catalog->GetDatabaseLocation("db3"));
+ ASSERT_OK_AND_ASSIGN(std::string db1_location, catalog->GetDatabaseLocation("db1"));
+ ASSERT_EQ("wh1/db1.db", db1_location);
+ // the location is resolved on the server, so an unknown database is reported as an error
+ Status no_location = catalog->GetDatabaseLocation("db3").status();
+ ASSERT_TRUE(no_location.IsNotExist()) << no_location.ToString();
+ // the virtual "sys" database is never asked about and has no location
+ ASSERT_OK_AND_ASSIGN(std::string sys_location, catalog->GetDatabaseLocation("sys"));
+ ASSERT_EQ("", sys_location);
ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/false, /*cascade=*/false));
ASSERT_OK(catalog->DropDatabase("db2", /*ignore_if_not_exists=*/true, /*cascade=*/false));