Files
test2/source/blender/blenkernel/intern/path_templates_test.cc
Nathan Vegdahl d7c6cbc6bf Core: Sanitize ID/struct names when used in path templates
In #139438 we added three path template variables that get their values
from user-specified names:

- `scene_name`
- `camera_name`
- `node_name`

These names can contain characters that are illegal in filenames on some
systems, and also characters like "/" that would be interpreted as path
separators in a filepath. This can lead to unexpected outcomes.

For example, if a node's name is "Hue/Saturation" and the `{node_name}`
variable is used in a path, it would result in a "Hue" directory which
in turn contains a "Saturation" file. This is almost certainly not what
the user intended, because the slash in that name is not semantically
supposed to be a path separator.

This PR addresses this problem by splitting string variables into two
types: strings and filepaths. The contents of string variables are
sanitized to be valid filenames when substituted into a path template,
but the contents of filepath variables are left as-is.

Variables such as `blend_name`, `blend_dir`, etc. that actually
represent path components are then added as filepath variables, while
variables such as `scene_name` that represent plain strings without path
semantics are added as string variables.

Pull Request: https://projects.blender.org/blender/blender/pulls/142679
2025-07-22 14:47:56 +02:00

451 lines
17 KiB
C++

/* SPDX-FileCopyrightText: 2025 Blender Authors
*
* SPDX-License-Identifier: GPL-2.0-or-later */
#include <fmt/format.h>
#include "BKE_path_templates.hh"
#include "testing/testing.h"
namespace blender::bke::tests {
using namespace blender::bke::path_templates;
static std::string error_to_string(const Error &error)
{
const char *type;
switch (error.type) {
case ErrorType::UNESCAPED_CURLY_BRACE:
type = "UNESCAPED_CURLY_BRACE";
break;
case ErrorType::VARIABLE_SYNTAX:
type = "VARIABLE_SYNTAX";
break;
case ErrorType::FORMAT_SPECIFIER:
type = "FORMAT_SPECIFIER";
break;
case ErrorType::UNKNOWN_VARIABLE:
type = "UNKNOWN_VARIABLE";
break;
}
std::string s;
fmt::format_to(std::back_inserter(s),
"({}, ({}, {}))",
type,
error.byte_range.start(),
error.byte_range.size());
return s;
}
static std::string errors_to_string(Span<Error> errors)
{
std::string s;
fmt::format_to(std::back_inserter(s), "[");
bool is_first = true;
for (const Error &error : errors) {
if (is_first) {
is_first = false;
}
else {
fmt::format_to(std::back_inserter(s), ", ");
}
fmt::format_to(std::back_inserter(s), "{}", error_to_string(error));
}
fmt::format_to(std::back_inserter(s), "]");
return s;
}
TEST(path_templates, VariableMap)
{
VariableMap map;
/* With in empty variable map, these should all return false / fail. */
EXPECT_FALSE(map.contains("hello"));
EXPECT_FALSE(map.remove("hello"));
EXPECT_EQ(std::nullopt, map.get_string("hello"));
EXPECT_EQ(std::nullopt, map.get_filepath("hello"));
EXPECT_EQ(std::nullopt, map.get_integer("hello"));
EXPECT_EQ(std::nullopt, map.get_float("hello"));
/* Populate the map. */
EXPECT_TRUE(map.add_string("hello", "What a wonderful world."));
EXPECT_TRUE(map.add_filepath("where", "/my/path"));
EXPECT_TRUE(map.add_integer("bye", 42));
EXPECT_TRUE(map.add_float("what", 3.14159));
/* Attempting to add variables with those names again should fail, since they
* already exist now. */
EXPECT_FALSE(map.add_string("hello", "Sup."));
EXPECT_FALSE(map.add_string("where", "Sup."));
EXPECT_FALSE(map.add_string("bye", "Sup."));
EXPECT_FALSE(map.add_string("what", "Sup."));
EXPECT_FALSE(map.add_filepath("hello", "/place"));
EXPECT_FALSE(map.add_filepath("where", "/place"));
EXPECT_FALSE(map.add_filepath("bye", "/place"));
EXPECT_FALSE(map.add_filepath("what", "/place"));
EXPECT_FALSE(map.add_integer("hello", 2));
EXPECT_FALSE(map.add_integer("where", 2));
EXPECT_FALSE(map.add_integer("bye", 2));
EXPECT_FALSE(map.add_integer("what", 2));
EXPECT_FALSE(map.add_float("hello", 2.71828));
EXPECT_FALSE(map.add_float("where", 2.71828));
EXPECT_FALSE(map.add_float("bye", 2.71828));
EXPECT_FALSE(map.add_float("what", 2.71828));
/* Confirm that the right variables exist. */
EXPECT_TRUE(map.contains("hello"));
EXPECT_TRUE(map.contains("where"));
EXPECT_TRUE(map.contains("bye"));
EXPECT_TRUE(map.contains("what"));
EXPECT_FALSE(map.contains("not here"));
/* Fetch the variables we added. */
EXPECT_EQ("What a wonderful world.", map.get_string("hello"));
EXPECT_EQ("/my/path", map.get_filepath("where"));
EXPECT_EQ(42, map.get_integer("bye"));
EXPECT_EQ(3.14159, map.get_float("what"));
/* The same variables shouldn't exist for the other types, despite our attempt
* to add them earlier. */
EXPECT_EQ(std::nullopt, map.get_filepath("hello"));
EXPECT_EQ(std::nullopt, map.get_integer("hello"));
EXPECT_EQ(std::nullopt, map.get_float("hello"));
EXPECT_EQ(std::nullopt, map.get_string("where"));
EXPECT_EQ(std::nullopt, map.get_integer("where"));
EXPECT_EQ(std::nullopt, map.get_float("where"));
EXPECT_EQ(std::nullopt, map.get_string("bye"));
EXPECT_EQ(std::nullopt, map.get_filepath("bye"));
EXPECT_EQ(std::nullopt, map.get_float("bye"));
EXPECT_EQ(std::nullopt, map.get_string("what"));
EXPECT_EQ(std::nullopt, map.get_filepath("what"));
EXPECT_EQ(std::nullopt, map.get_integer("what"));
/* Remove the variables. */
EXPECT_TRUE(map.remove("hello"));
EXPECT_TRUE(map.remove("where"));
EXPECT_TRUE(map.remove("bye"));
EXPECT_TRUE(map.remove("what"));
/* The variables shouldn't exist anymore. */
EXPECT_FALSE(map.contains("hello"));
EXPECT_FALSE(map.contains("where"));
EXPECT_FALSE(map.contains("bye"));
EXPECT_FALSE(map.contains("what"));
EXPECT_EQ(std::nullopt, map.get_string("hello"));
EXPECT_EQ(std::nullopt, map.get_filepath("where"));
EXPECT_EQ(std::nullopt, map.get_integer("bye"));
EXPECT_EQ(std::nullopt, map.get_float("what"));
EXPECT_FALSE(map.remove("hello"));
EXPECT_FALSE(map.remove("where"));
EXPECT_FALSE(map.remove("bye"));
EXPECT_FALSE(map.remove("what"));
}
TEST(path_templates, VariableMap_add_filename_only)
{
VariableMap map;
EXPECT_TRUE(map.add_filename_only("a", "/home/bob/project_joe/scene_3.blend", "fallback"));
EXPECT_EQ("scene_3", map.get_filepath("a"));
EXPECT_TRUE(map.add_filename_only("b", "/home/bob/project_joe/scene_3", "fallback"));
EXPECT_EQ("scene_3", map.get_filepath("b"));
EXPECT_TRUE(map.add_filename_only("c", "/home/bob/project_joe/scene.03.blend", "fallback"));
EXPECT_EQ("scene.03", map.get_filepath("c"));
EXPECT_TRUE(map.add_filename_only("d", "/home/bob/project_joe/.scene_3.blend", "fallback"));
EXPECT_EQ(".scene_3", map.get_filepath("d"));
EXPECT_TRUE(map.add_filename_only("e", "/home/bob/project_joe/.scene_3", "fallback"));
EXPECT_EQ(".scene_3", map.get_filepath("e"));
EXPECT_TRUE(map.add_filename_only("f", "scene_3.blend", "fallback"));
EXPECT_EQ("scene_3", map.get_filepath("f"));
EXPECT_TRUE(map.add_filename_only("g", "scene_3", "fallback"));
EXPECT_EQ("scene_3", map.get_filepath("g"));
/* No filename in path (ending slash means it's a directory). */
EXPECT_TRUE(map.add_filename_only("h", "/home/bob/project_joe/", "fallback"));
EXPECT_EQ("fallback", map.get_filepath("h"));
/* Empty path. */
EXPECT_TRUE(map.add_filename_only("i", "", "fallback"));
EXPECT_EQ("fallback", map.get_filepath("i"));
/* Attempt to add already-added variable. */
EXPECT_FALSE(map.add_filename_only("i", "", "fallback"));
}
TEST(path_templates, VariableMap_add_path_up_to_file)
{
VariableMap map;
EXPECT_TRUE(map.add_path_up_to_file("a", "/home/bob/project_joe/scene_3.blend", "fallback"));
EXPECT_EQ("/home/bob/project_joe/", map.get_filepath("a"));
EXPECT_TRUE(map.add_path_up_to_file("b", "project_joe/scene_3.blend", "fallback"));
EXPECT_EQ("project_joe/", map.get_filepath("b"));
EXPECT_TRUE(map.add_path_up_to_file("c", "/scene_3.blend", "fallback"));
EXPECT_EQ("/", map.get_filepath("c"));
/* No filename in path (ending slash means it's a directory). */
EXPECT_TRUE(map.add_path_up_to_file("e", "/home/bob/project_joe/", "fallback"));
EXPECT_EQ("/home/bob/project_joe/", map.get_filepath("e"));
EXPECT_TRUE(map.add_path_up_to_file("f", "/", "fallback"));
EXPECT_EQ("/", map.get_filepath("f"));
/* No leading path. */
EXPECT_TRUE(map.add_path_up_to_file("d", "scene_3.blend", "fallback"));
EXPECT_EQ("fallback", map.get_filepath("d"));
/* Empty path. */
EXPECT_TRUE(map.add_path_up_to_file("g", "", "fallback"));
EXPECT_EQ("fallback", map.get_filepath("g"));
/* Attempt to add already-added variable. */
EXPECT_FALSE(map.add_filename_only("g", "", "fallback"));
}
struct PathTemplateTestCase {
char path_in[FILE_MAX];
char path_result[FILE_MAX];
Vector<Error> expected_errors;
};
TEST(path_templates, validate_and_apply_template)
{
VariableMap variables;
{
variables.add_string("hi", "hello");
variables.add_string("bye", "goodbye");
variables.add_string("empty", "");
variables.add_string("sanitize", "./\\?*:|\"<>");
variables.add_string("long", "This string is exactly 32 bytes_");
variables.add_filepath("path", "C:\\and/or/../nor/");
variables.add_integer("the_answer", 42);
variables.add_integer("prime", 7);
variables.add_integer("i_negative", -7);
variables.add_float("pi", 3.14159265358979323846);
variables.add_float("e", 2.71828182845904523536);
variables.add_float("ntsc", 30.0 / 1.001);
variables.add_float("two", 2.0);
variables.add_float("f_negative", -3.14159265358979323846);
variables.add_float("huge", 200000000000000000000000000000000.0);
variables.add_float("tiny", 0.000000000000000000000000000000002);
}
const Vector<PathTemplateTestCase> test_cases = {
/* Simple case, testing all variables. */
{
"{hi}_{bye}_{empty}_{sanitize}_{path}"
"_{the_answer}_{prime}_{i_negative}_{pi}_{e}_{ntsc}_{two}"
"_{f_negative}_{huge}_{tiny}",
"hello_goodbye__.__________C:\\and/or/../nor/"
"_42_7_-7_3.141592653589793_2.718281828459045_29.970029970029973_2.0"
"_-3.141592653589793_2e+32_2e-33",
{},
},
/* Integer formatting. */
{
"{the_answer:#}_{the_answer:##}_{the_answer:####}_{i_negative:####}",
"42_42_0042_-007",
{},
},
/* Integer formatting as float. */
{
"{the_answer:.###}_{the_answer:#.##}_{the_answer:###.##}_{i_negative:###.####}",
"42.000_42.00_042.00_-07.0000",
{},
},
/* Float formatting: specify fractional digits only. */
{
"{pi:.####}_{e:.###}_{ntsc:.########}_{two:.##}_{f_negative:.##}_{huge:.##}_{tiny:.##}",
"3.1416_2.718_29.97002997_2.00_-3.14_200000000000000010732324408786944.00_0.00",
{},
},
/* Float formatting: specify both integer and fractional digits. */
{
"{pi:##.####}_{e:####.###}_{ntsc:#.########}_{two:###.##}_{f_negative:###.##}_{huge:###."
"##}_{tiny:###.##}",
"03.1416_0002.718_29.97002997_002.00_-03.14_200000000000000010732324408786944.00_000.00",
{},
},
/* Float formatting: format as integer. */
{
"{pi:##}_{e:####}_{ntsc:#}_{two:###}",
"03_0003_30_002",
{},
},
/* Escaping. "{{" and "}}" are the escape codes for literal "{" and "}". */
{
"{hi}_{{hi}}_{{{bye}}}_{bye}",
"hello_{hi}_{goodbye}_goodbye",
{},
},
/* Error: string variables do not support format specifiers. */
{
"{hi:##}_{bye:#}",
"{hi:##}_{bye:#}",
{
{ErrorType::FORMAT_SPECIFIER, IndexRange(0, 7)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(8, 7)},
},
},
/* Error: float formatting: specifying integer digits only (but still wanting
* it printed as a float) is currently not supported. */
{
"{pi:##.}_{e:####.}_{ntsc:#.}_{two:###.}_{f_negative:###.}_{huge:###.}_{tiny:###.}",
"{pi:##.}_{e:####.}_{ntsc:#.}_{two:###.}_{f_negative:###.}_{huge:###.}_{tiny:###.}",
{
{ErrorType::FORMAT_SPECIFIER, IndexRange(0, 8)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(9, 9)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(19, 9)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(29, 10)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(40, 17)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(58, 11)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(70, 11)},
},
},
/* Error: missing variable. */
{
"{hi}_{missing}_{bye}",
"{hi}_{missing}_{bye}",
{
{ErrorType::UNKNOWN_VARIABLE, IndexRange(5, 9)},
},
},
/* Error: incomplete variable expression. */
{
"foo{hi",
"foo{hi",
{
{ErrorType::VARIABLE_SYNTAX, IndexRange(3, 3)},
},
},
/* Error: incomplete variable expression after complete one. */
{
"foo{bye}{hi",
"foo{bye}{hi",
{
{ErrorType::VARIABLE_SYNTAX, IndexRange(8, 3)},
},
},
/* Error: invalid format specifiers. */
{
"{prime:}_{prime:.}_{prime:#.#.#}_{prime:sup}_{prime::sup}_{prime}",
"{prime:}_{prime:.}_{prime:#.#.#}_{prime:sup}_{prime::sup}_{prime}",
{
{ErrorType::FORMAT_SPECIFIER, IndexRange(0, 8)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(9, 9)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(19, 13)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(33, 11)},
{ErrorType::FORMAT_SPECIFIER, IndexRange(45, 12)},
},
},
/* Error: unclosed variable. */
{
"{hi_{hi}_{bye}",
"{hi_{hi}_{bye}",
{
{ErrorType::VARIABLE_SYNTAX, IndexRange(0, 4)},
},
},
/* Error: escaped braces inside variable. */
{
"{hi_{{hi}}_{bye}",
"{hi_{{hi}}_{bye}",
{
{ErrorType::VARIABLE_SYNTAX, IndexRange(0, 4)},
},
},
/* Test what happens when the path would expand to a string that's longer than
* `FILE_MAX`.
*
* We don't care so much about any kind of "correctness" here, we just want to
* ensure that it still results in a valid null-terminated string that fits in
* `FILE_MAX` bytes.
*
* NOTE: this test will have to be updated if `FILE_MAX` is ever changed. */
{
"___{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}"
"{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{long}{"
"long}{long}",
"___This string is exactly 32 bytes_This string is exactly 32 bytes_This string is "
"exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This "
"string is exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 "
"bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This string is "
"exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This "
"string is exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 "
"bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This string is "
"exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This "
"string is exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 "
"bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This string is "
"exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 bytes_This "
"string is exactly 32 bytes_This string is exactly 32 bytes_This string is exactly 32 "
"by",
{},
},
};
for (const PathTemplateTestCase &test_case : test_cases) {
char path[FILE_MAX];
STRNCPY(path, test_case.path_in);
/* Do validation first, which shouldn't modify the path. */
const Vector<Error> validation_errors = BKE_path_validate_template(path, variables);
EXPECT_EQ(validation_errors, test_case.expected_errors)
<< " Template errors: " << errors_to_string(validation_errors) << std::endl
<< " Expected errors: " << errors_to_string(test_case.expected_errors) << std::endl
<< " Note: test_case.path_in = " << test_case.path_in << std::endl;
EXPECT_EQ(blender::StringRef(path), test_case.path_in)
<< " Note: test_case.path_in = " << test_case.path_in << std::endl;
/* Then do application, which should modify the path. */
const Vector<Error> application_errors = BKE_path_apply_template(path, FILE_MAX, variables);
EXPECT_EQ(application_errors, test_case.expected_errors)
<< " Template errors: " << errors_to_string(application_errors) << std::endl
<< " Expected errors: " << errors_to_string(test_case.expected_errors) << std::endl
<< " Note: test_case.path_in = " << test_case.path_in << std::endl;
EXPECT_EQ(blender::StringRef(path), test_case.path_result)
<< " Note: test_case.path_in = " << test_case.path_in << std::endl;
}
}
} // namespace blender::bke::tests