Reject 3MF component references that form a cycle (#16059)

This commit is contained in:
SoftFever
2026-10-09 19:23:20 +08:00
committed by GitHub
2 changed files with 83 additions and 4 deletions
+20 -4
View File
@@ -1395,7 +1395,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
bool _handle_start_relationship(const char** attributes, unsigned int num_attributes);
void _generate_current_object_list(std::vector<Component> &sub_objects, Id object_id, IdToCurrentObjectMap& current_objects);
bool _generate_current_object_list(std::vector<Component> &sub_objects, Id object_id, IdToCurrentObjectMap& current_objects);
bool _generate_volumes_new(ModelObject& object, const std::vector<Component> &sub_objects, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions);
//bool _generate_volumes(ModelObject& object, const Geometry& geometry, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions);
@@ -2117,7 +2117,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
return false;
}
std::vector<Component> object_id_list;
_generate_current_object_list(object_id_list, object.first, m_current_objects);
if (!_generate_current_object_list(object_id_list, object.first, m_current_objects))
return false;
ObjectMetadata::VolumeMetadataList volumes;
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
@@ -2216,7 +2217,8 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}*/
std::vector<Component> object_id_list;
_generate_current_object_list(object_id_list, object.first, m_current_objects);
if (!_generate_current_object_list(object_id_list, object.first, m_current_objects))
return false;
ObjectMetadata::VolumeMetadataList volumes;
ObjectMetadata::VolumeMetadataList* volumes_ptr = nullptr;
@@ -5071,11 +5073,18 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
return true;
}
void _BBS_3MF_Importer::_generate_current_object_list(std::vector<Component> &sub_objects, Id object_id, IdToCurrentObjectMap &current_objects)
bool _BBS_3MF_Importer::_generate_current_object_list(std::vector<Component> &sub_objects, Id object_id, IdToCurrentObjectMap &current_objects)
{
// A cycle in the component graph would expand forever, and an acyclic graph can still expand
// exponentially, so bound the number of component references queued. Checking before they are
// queued bounds the work list itself, whatever the fan-out. A valid file over the budget is
// rejected too, but the budget is way above the component references of any real object.
static constexpr size_t max_components = 100000;
std::list<std::pair<Component, Transform3d>> id_list;
id_list.push_back(std::make_pair(Component(object_id, Transform3d::Identity()), Transform3d::Identity()));
size_t num_components = 0;
while (!id_list.empty())
{
auto current_item = id_list.front();
@@ -5085,6 +5094,12 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
if (current_object != current_objects.end()) {
//found one
if (!current_object->second.components.empty()) {
num_components += current_object->second.components.size();
if (num_components > max_components) {
add_error("invalid 3mf: cyclic or too many component references");
sub_objects.clear();
return false;
}
for (const Component &comp : current_object->second.components) {
id_list.push_back(std::pair(comp, current_item.second * comp.transform));
}
@@ -5096,6 +5111,7 @@ void PlateData::parse_filament_info(GCodeProcessorResult *result)
}
}
}
return true;
}
bool _BBS_3MF_Importer::_generate_volumes_new(ModelObject& object, const std::vector<Component> &sub_objects, const ObjectMetadata::VolumeMetadataList& volumes, ConfigSubstitutionContext& config_substitutions)
+63
View File
@@ -45,6 +45,7 @@
#include <sstream>
#include <type_traits> // for std::enable_if_t
#include <typeinfo> // for typeid
#include <regex>
#include <vector>
#include <utility>
@@ -415,6 +416,68 @@ TEST_CASE("A project with a plate id below 1 fails to load", "[3mf][Regression]"
REQUIRE_FALSE(loaded);
}
TEST_CASE("A project whose components reference themselves fails to load", "[3mf][Regression]")
{
// One self-reference keeps the expansion going without ever reaching a mesh. A thousand also make
// each expansion queue a thousand more, so the bound has to hold the work list, not just the loop.
const int references = GENERATE(1, 1000);
INFO("self-references " << references);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
// Point the component back at the object that holds it, repeated `references` times.
REQUIRE(rewrite_3mf_entries(temp.string(), [references](std::string& name, std::string& data) {
if (!boost::algorithm::ends_with(name, "3dmodel.model"))
return false;
std::smatch match;
if (!std::regex_search(data, match, std::regex("<object id=\"([0-9]+)\"[^>]*>\\s*<components")))
return false;
data = std::regex_replace(data, std::regex("objectid=\"[0-9]+\""), "objectid=\"" + match[1].str() + "\"");
std::smatch component;
if (!std::regex_search(data, component, std::regex("<component [^>]*/>")))
return false;
std::string repeated;
for (int i = 0; i < references; ++i)
repeated += component.str();
data.replace(component.position(), component.length(), repeated);
return true;
}));
ScopedTemporaryDir backup_dir("orca_cycle_dst");
Model model;
bool loaded = true;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE_FALSE(loaded);
}
TEST_CASE("An object loads up to the component reference budget and fails past it", "[3mf][Regression]")
{
// The importer queues at most 100000 component references per object. Every reference besides the
// cube's own points at an object the file does not define: it counts toward the budget, then expands
// to nothing, so the object stays a single part whatever the count.
const auto [references, loads] = GENERATE(table<int, bool>({ { 100000, true }, { 100001, false } }));
INFO("component references " << references);
ScopedTemporaryFile temp(".3mf");
store_painted_cube(temp.string());
std::string dangling;
for (int i = 1; i < references; ++i)
dangling += "<component objectid=\"999999\"/>";
REQUIRE(replace_in_3mf_entry(temp.string(), "3dmodel.model", "</components>", dangling + "</components>"));
ScopedTemporaryDir backup_dir("orca_budget_dst");
Model model;
bool loaded = !loads;
REQUIRE_NOTHROW(loaded = load_project(temp.string(), model, backup_dir));
REQUIRE(loaded == loads);
if (loads) {
REQUIRE(model.objects.size() == 1);
CHECK(model.objects.front()->volumes.size() == 1);
}
}
TEST_CASE("A project with malformed paint data loads without the damaged facet", "[3mf][Regression]")
{
ScopedTemporaryFile temp(".3mf");