Skip to content

Commit 8a1e74b

Browse files
authored
fix(catalog): validate identifier names used to build catalog paths (#264)
1 parent a127a7f commit 8a1e74b

21 files changed

Lines changed: 432 additions & 36 deletions

include/paimon/catalog/catalog.h

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -162,19 +162,20 @@ class PAIMON_EXPORT Catalog {
162162

163163
/// Returns the expected location of a specified database.
164164
///
165-
/// @note This does not check whether the database actually exists.
166-
///
167165
/// @param db_name The name of the database to get the location for.
168-
/// @return A string representing the expected location of the database.
169-
virtual std::string GetDatabaseLocation(const std::string& db_name) const = 0;
166+
/// @return A result containing the expected location of the database, or an error status on
167+
/// failure. An implementation that builds the location from the warehouse path, such as the
168+
/// file system catalog, answers without checking whether the database exists. One that resolves
169+
/// the location on a server, such as the REST catalog, propagates the server's error and so
170+
/// fails for a database that does not exist.
171+
virtual Result<std::string> GetDatabaseLocation(const std::string& db_name) const = 0;
170172

171173
/// Returns the expected location of a specified table.
172174
///
173-
/// @note This does not check whether the table actually exists.
174-
///
175175
/// @param identifier The table identifier containing database and table name.
176176
/// @return A result containing the expected location of the table, or an error status on
177-
/// failure.
177+
/// failure. Whether a missing table is an error depends on the implementation, in the same way
178+
/// as for `GetDatabaseLocation`.
178179
virtual Result<std::string> GetTableLocation(const Identifier& identifier) const = 0;
179180

180181
/// Returns the root path of the catalog.

src/paimon/common/utils/path_util.cpp

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,8 @@
2121

2222
#include <unistd.h>
2323

24+
#include <algorithm>
25+
#include <cctype>
2426
#include <cerrno>
2527
#include <cstddef>
2628
#include <cstdint>
@@ -34,6 +36,41 @@
3436
#include "paimon/status.h"
3537

3638
namespace paimon {
39+
namespace {
40+
41+
/// Escapes the control characters of `name`, so that a rejected name cannot inject a line into
42+
/// the log the error is written to nor truncate the C string it is copied into. Bytes of a
43+
/// multi-byte sequence are left alone, since none of them is a control character.
44+
std::string EscapeControlCharacters(const std::string& name) {
45+
std::string escaped;
46+
escaped.reserve(name.size());
47+
for (char c : name) {
48+
switch (c) {
49+
case '\\':
50+
escaped += "\\\\";
51+
break;
52+
case '\n':
53+
escaped += "\\n";
54+
break;
55+
case '\r':
56+
escaped += "\\r";
57+
break;
58+
case '\t':
59+
escaped += "\\t";
60+
break;
61+
default:
62+
if (std::iscntrl(static_cast<unsigned char>(c)) != 0) {
63+
escaped += fmt::format("\\x{{{:02x}}}", static_cast<unsigned char>(c));
64+
} else {
65+
escaped += c;
66+
}
67+
}
68+
}
69+
return escaped;
70+
}
71+
72+
} // namespace
73+
3774
std::string Path::ToString() const {
3875
std::string ret;
3976
if (!scheme.empty()) {
@@ -169,4 +206,24 @@ Result<std::string> PathUtil::CreateTempPath(const std::string& path) noexcept {
169206
return JoinPath(GetParentDirPath(path), fmt::format(".{}.{}.tmp", GetName(path), uuid));
170207
}
171208

209+
Status PathUtil::CheckSinglePathComponent(const std::string& kind, const std::string& name) {
210+
const char* reason = nullptr;
211+
if (StringUtils::IsNullOrWhitespaceOnly(name)) {
212+
reason = "cannot be empty or whitespace";
213+
} else if (name == "." || name == "..") {
214+
reason = "cannot be '.' or '..'";
215+
} else if (name.find('/') != std::string::npos || name.find('\\') != std::string::npos) {
216+
reason = "cannot contain path separators";
217+
} else if (std::any_of(name.begin(), name.end(), [](char c) {
218+
return std::iscntrl(static_cast<unsigned char>(c)) != 0;
219+
})) {
220+
reason = "cannot contain control characters";
221+
}
222+
if (reason != nullptr) {
223+
return Status::Invalid(
224+
fmt::format("{} name {}: '{}'", kind, reason, EscapeControlCharacters(name)));
225+
}
226+
return Status::OK();
227+
}
228+
172229
} // namespace paimon

src/paimon/common/utils/path_util.h

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,15 @@ class PAIMON_EXPORT PathUtil {
5252
static Result<Path> ToPath(const std::string& path) noexcept;
5353
static Result<std::string> NormalizePath(const std::string& path) noexcept;
5454

55+
/// Fails when `name` cannot be used as a single path component, which is required to keep a
56+
/// path built with `JoinPath` under the directory it is joined to: `name` must not be empty
57+
/// or whitespace-only, must not be "." or "..", and must contain neither a path separator
58+
/// nor a control character. `kind` names the rejected value in the error message, which
59+
/// reads "<kind> name <reason>: '<name>'" and escapes the control characters of `name`.
60+
///
61+
/// The check is purely lexical and needs no IO.
62+
static Status CheckSinglePathComponent(const std::string& kind, const std::string& name);
63+
5564
private:
5665
static std::string NormalizeInnerPath(const std::string& path) noexcept;
5766
};

src/paimon/common/utils/path_util_test.cpp

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,4 +171,43 @@ TEST(PathUtilsTest, TestCreateTempPath) {
171171
ASSERT_TRUE(StringUtils::EndsWith(tmp_name, ".tmp"));
172172
}
173173

174+
TEST(PathUtilsTest, TestCheckSinglePathComponent) {
175+
// Names that stay a single path component, including names that merely contain a dot and
176+
// names outside ascii.
177+
for (const char* name : {"db1", "my.db", "a..b", "a b", "数据", "\u00e9t\u00e9"}) {
178+
ASSERT_OK(PathUtil::CheckSinglePathComponent("database", name));
179+
}
180+
181+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("database", ""),
182+
"database name cannot be empty or whitespace");
183+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("database", " \t\n "),
184+
"database name cannot be empty or whitespace");
185+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "."),
186+
"table name cannot be '.' or '..'");
187+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", ".."),
188+
"table name cannot be '.' or '..'");
189+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "../escaped"),
190+
"table name cannot contain path separators");
191+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("table", "back\\slash"),
192+
"table name cannot contain path separators");
193+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("branch", "line\nfeed"),
194+
"branch name cannot contain control characters");
195+
ASSERT_NOK_WITH_MSG(PathUtil::CheckSinglePathComponent("branch", std::string("nul\0byte", 8)),
196+
"branch name cannot contain control characters");
197+
}
198+
199+
TEST(PathUtilsTest, TestCheckSinglePathComponentEscapesRejectedName) {
200+
// The rejected name is escaped, so that it can neither add a line to the log the error is
201+
// written to nor truncate the C string it is copied into.
202+
Status newline = PathUtil::CheckSinglePathComponent("database", "line\nfeed\r\t");
203+
ASSERT_FALSE(newline.ok());
204+
ASSERT_EQ(newline.ToString().find('\n'), std::string::npos);
205+
ASSERT_NE(newline.ToString().find("line\\nfeed\\r\\t"), std::string::npos);
206+
207+
Status nul = PathUtil::CheckSinglePathComponent("database", std::string("nul\0byte", 8));
208+
ASSERT_FALSE(nul.ok());
209+
ASSERT_EQ(nul.ToString().find('\0'), std::string::npos);
210+
ASSERT_NE(nul.ToString().find("nul\\x{00}byte"), std::string::npos);
211+
}
212+
174213
} // namespace paimon::test

src/paimon/core/catalog/catalog_utils.cpp

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,8 @@
2222

2323
#include "fmt/format.h"
2424
#include "paimon/catalog/catalog.h"
25+
#include "paimon/common/utils/path_util.h"
26+
#include "paimon/core/utils/branch_manager.h"
2527
#include "paimon/result.h"
2628

2729
namespace paimon {
@@ -70,4 +72,26 @@ Status CatalogUtils::CheckNotBranch(const Identifier& identifier, const std::str
7072
return Status::OK();
7173
}
7274

75+
Status CatalogUtils::CheckValidDatabaseName(const std::string& db_name) {
76+
return PathUtil::CheckSinglePathComponent("database", db_name);
77+
}
78+
79+
Status CatalogUtils::CheckValidTableName(const Identifier& identifier) {
80+
PAIMON_ASSIGN_OR_RAISE(std::string data_table_name, identifier.GetDataTableName());
81+
PAIMON_RETURN_NOT_OK(PathUtil::CheckSinglePathComponent("table", data_table_name));
82+
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> branch, identifier.GetBranchName());
83+
if (branch) {
84+
// The branch of an identifier selects the same directory as the `branch` option, so both
85+
// go through the same check.
86+
PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(branch.value()));
87+
}
88+
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table,
89+
identifier.GetSystemTableName());
90+
if (system_table) {
91+
PAIMON_RETURN_NOT_OK(
92+
PathUtil::CheckSinglePathComponent("system table", system_table.value()));
93+
}
94+
return Status::OK();
95+
}
96+
7397
} // namespace paimon

src/paimon/core/catalog/catalog_utils.h

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,14 @@ class CatalogUtils {
4343

4444
/// Fails when `identifier` carries a "$branch_" suffix.
4545
static Status CheckNotBranch(const Identifier& identifier, const std::string& action);
46+
47+
/// Fails when `db_name` cannot be used as a single path component, which is required to
48+
/// keep the database path under the warehouse.
49+
static Status CheckValidDatabaseName(const std::string& db_name);
50+
51+
/// Fails when any component parsed out of the identifier's table name (data table name,
52+
/// branch name, system table name) cannot be used as a single path component.
53+
static Status CheckValidTableName(const Identifier& identifier);
4654
};
4755

4856
} // namespace paimon

src/paimon/core/catalog/file_system_catalog.cpp

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -87,7 +87,7 @@ Status FileSystemCatalog::CreateDatabaseImpl(const std::string& db_name,
8787
fmt::join(options, ", "));
8888
PAIMON_LOG_DEBUG(logger_, "%s", log_msg.c_str());
8989
}
90-
std::string db_path = NewDatabasePath(warehouse_, db_name);
90+
PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, db_name));
9191
PAIMON_RETURN_NOT_OK(fs_->Mkdirs(db_path));
9292
return Status::OK();
9393
}
@@ -96,14 +96,18 @@ Result<bool> FileSystemCatalog::DatabaseExists(const std::string& db_name) const
9696
if (CatalogUtils::IsSystemDatabase(db_name)) {
9797
return true;
9898
}
99-
return fs_->Exists(NewDatabasePath(warehouse_, db_name));
99+
PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, db_name));
100+
return fs_->Exists(db_path);
100101
}
101102

102103
Result<bool> FileSystemCatalog::TableExists(const Identifier& identifier) const {
103104
// Handle sys database global tables
104105
if (CatalogUtils::IsSystemDatabase(identifier.GetDatabaseName())) {
105106
return GlobalSystemTableLoader::IsSupported(identifier.GetTableName(), catalog_options_);
106107
}
108+
// The branch component is dropped when the data table identifier is rebuilt below, so the
109+
// identifier is validated as a whole here.
110+
PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
107111
PAIMON_ASSIGN_OR_RAISE(bool is_system_table, identifier.IsSystemTable());
108112
if (is_system_table) {
109113
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table_name,
@@ -122,7 +126,7 @@ Result<bool> FileSystemCatalog::TableExists(const Identifier& identifier) const
122126
return latest_schema != std::nullopt;
123127
}
124128

125-
std::string FileSystemCatalog::GetDatabaseLocation(const std::string& db_name) const {
129+
Result<std::string> FileSystemCatalog::GetDatabaseLocation(const std::string& db_name) const {
126130
return NewDatabasePath(warehouse_, db_name);
127131
}
128132

@@ -204,16 +208,19 @@ Result<bool> FileSystemCatalog::IsSystemTable(const Identifier& identifier) {
204208
return IsSpecifiedSystemTable(identifier);
205209
}
206210

207-
std::string FileSystemCatalog::NewDatabasePath(const std::string& warehouse,
208-
const std::string& db_name) {
211+
Result<std::string> FileSystemCatalog::NewDatabasePath(const std::string& warehouse,
212+
const std::string& db_name) {
213+
PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidDatabaseName(db_name));
209214
return PathUtil::JoinPath(warehouse, db_name + DB_SUFFIX);
210215
}
211216

212217
Result<std::string> FileSystemCatalog::NewDataTablePath(const std::string& warehouse,
213218
const Identifier& identifier) {
219+
PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
214220
PAIMON_ASSIGN_OR_RAISE(std::string data_table_name, identifier.GetDataTableName());
215-
return PathUtil::JoinPath(NewDatabasePath(warehouse, identifier.GetDatabaseName()),
216-
data_table_name);
221+
PAIMON_ASSIGN_OR_RAISE(std::string database_path,
222+
NewDatabasePath(warehouse, identifier.GetDatabaseName()));
223+
return PathUtil::JoinPath(database_path, data_table_name);
217224
}
218225

219226
Result<std::vector<std::string>> FileSystemCatalog::ListDatabases() const {
@@ -235,7 +242,7 @@ Result<std::vector<std::string>> FileSystemCatalog::ListTables(const std::string
235242
if (CatalogUtils::IsSystemDatabase(db_name)) {
236243
return GlobalSystemTableLoader::GetSupportedTableNames(catalog_options_);
237244
}
238-
std::string database_path = NewDatabasePath(warehouse_, db_name);
245+
PAIMON_ASSIGN_OR_RAISE(std::string database_path, NewDatabasePath(warehouse_, db_name));
239246
std::vector<BasicFileStatus> file_status_list;
240247
PAIMON_RETURN_NOT_OK(fs_->ListDir(database_path, &file_status_list));
241248
std::vector<std::string> table_names;
@@ -284,6 +291,9 @@ Result<std::shared_ptr<Schema>> FileSystemCatalog::LoadTableSchema(
284291
system_table->ArrowSchema());
285292
return std::make_shared<SystemTableSchema>(std::move(arrow_schema));
286293
}
294+
// The branch component is dropped when the data table identifier is rebuilt below, so the
295+
// identifier is validated as a whole here.
296+
PAIMON_RETURN_NOT_OK(CatalogUtils::CheckValidTableName(identifier));
287297
PAIMON_ASSIGN_OR_RAISE(bool is_system_table, identifier.IsSystemTable());
288298
if (is_system_table) {
289299
PAIMON_ASSIGN_OR_RAISE(std::optional<std::string> system_table_name,
@@ -342,7 +352,7 @@ Status FileSystemCatalog::DropDatabase(const std::string& name, bool ignore_if_n
342352
}
343353
}
344354

345-
std::string db_path = NewDatabasePath(warehouse_, name);
355+
PAIMON_ASSIGN_OR_RAISE(std::string db_path, NewDatabasePath(warehouse_, name));
346356

347357
if (cascade) {
348358
// List all tables in the database and drop them
@@ -511,6 +521,7 @@ Status FileSystemCatalog::RenameTable(const Identifier& from_table, const Identi
511521

512522
Result<std::vector<SnapshotInfo>> FileSystemCatalog::ListSnapshots(
513523
const Identifier& identifier, const std::string& branch) const {
524+
PAIMON_RETURN_NOT_OK(BranchManager::CheckValidBranch(branch));
514525
PAIMON_ASSIGN_OR_RAISE(bool exists, TableExists(identifier));
515526
if (!exists) {
516527
return Status::NotExist(fmt::format("table {} does not exist", identifier.ToString()));

src/paimon/core/catalog/file_system_catalog.h

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,7 +59,7 @@ class FileSystemCatalog : public Catalog {
5959
Result<std::vector<std::string>> ListTables(const std::string& db_name) const override;
6060
Result<bool> DatabaseExists(const std::string& db_name) const override;
6161
Result<bool> TableExists(const Identifier& identifier) const override;
62-
std::string GetDatabaseLocation(const std::string& db_name) const override;
62+
Result<std::string> GetDatabaseLocation(const std::string& db_name) const override;
6363
Result<std::string> GetTableLocation(const Identifier& identifier) const override;
6464
Result<std::shared_ptr<Schema>> LoadTableSchema(const Identifier& identifier) const override;
6565
std::string GetRootPath() const override;
@@ -70,7 +70,12 @@ class FileSystemCatalog : public Catalog {
7070
const std::string& branch) const override;
7171

7272
private:
73-
static std::string NewDatabasePath(const std::string& warehouse, const std::string& db_name);
73+
/// Fails when `db_name` cannot be used as a single path component, so that the returned
74+
/// path always stays under `warehouse`.
75+
static Result<std::string> NewDatabasePath(const std::string& warehouse,
76+
const std::string& db_name);
77+
/// Fails when the database name or any component of the table name cannot be used as a
78+
/// single path component, so that the returned path always stays under `warehouse`.
7479
static Result<std::string> NewDataTablePath(const std::string& warehouse,
7580
const Identifier& identifier);
7681
static Result<bool> IsSpecifiedSystemTable(const Identifier& identifier);

0 commit comments

Comments
 (0)