forked from 0ad/0ad
# Automatic runtime validation of entity template files.
Fixes #413. This was SVN commit r7455.
This commit is contained in:
parent
f9195d8a29
commit
3117f52d7c
@ -1,5 +1,7 @@
|
||||
function TestScript2A() {}
|
||||
|
||||
TestScript2A.prototype.Schema = "<ref name='anything'/>";
|
||||
|
||||
TestScript2A.prototype.Init = function() {
|
||||
this.x = eval(this.template.y);
|
||||
};
|
||||
|
@ -1,5 +1,7 @@
|
||||
function HotloadA() {}
|
||||
|
||||
HotloadA.prototype.Schema = "<ref name='anything'/>";
|
||||
|
||||
HotloadA.prototype.Init = function() {
|
||||
this.x = +this.template.x;
|
||||
};
|
||||
|
@ -1,5 +1,7 @@
|
||||
function HotloadA() {}
|
||||
|
||||
HotloadA.prototype.Schema = "<ref name='anything'/>";
|
||||
|
||||
HotloadA.prototype.Init = function() {
|
||||
this.x = +this.template.x;
|
||||
};
|
||||
|
@ -61,6 +61,8 @@ Engine.RegisterComponentType(IID_Test1, "TestScript1_getter", TestScript1_getter
|
||||
|
||||
function TestScript1_consts() {}
|
||||
|
||||
TestScript1_consts.prototype.Schema = "<ref name='anything'/>";
|
||||
|
||||
TestScript1_consts.prototype.GetX = function() {
|
||||
return (+this.entity) + (+this.template.x);
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Entity>
|
||||
<x>12345</x>
|
||||
<Test1A>12345</Test1A>
|
||||
</Entity>
|
||||
|
@ -0,0 +1,5 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
bogus
|
||||
<Entity>
|
||||
<Test1A>12345</Test1A>
|
||||
</Entity>
|
@ -1,8 +1,8 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Entity>
|
||||
<x a="a1" b="b1" c="c1">
|
||||
<Test1A a="a1" b="b1" c="c1">
|
||||
<d>d1</d>
|
||||
<e>e1</e>
|
||||
<f>f1</f>
|
||||
</x>
|
||||
</Test1A>
|
||||
</Entity>
|
||||
|
@ -1,7 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Entity parent="inherit1">
|
||||
<x a="a2">
|
||||
<Test1A a="a2">
|
||||
<d>d2</d>
|
||||
<g>g2</g>
|
||||
</x>
|
||||
</Test1A>
|
||||
</Entity>
|
||||
|
@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<Entity>
|
||||
<Test1B>12345</Test1B>
|
||||
</Entity>
|
@ -12,6 +12,7 @@
|
||||
<UnitMotion/>
|
||||
<Footprint>
|
||||
<Circle radius="4"/>
|
||||
<Height>1.0</Height>
|
||||
</Footprint>
|
||||
<Obstruction/>
|
||||
</Entity>
|
||||
|
98
source/ps/XML/RelaxNG.cpp
Normal file
98
source/ps/XML/RelaxNG.cpp
Normal file
@ -0,0 +1,98 @@
|
||||
/* Copyright (C) 2010 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "precompiled.h"
|
||||
|
||||
#include "RelaxNG.h"
|
||||
|
||||
#include "lib/timer.h"
|
||||
#include "lib/utf8.h"
|
||||
#include "ps/CLogger.h"
|
||||
|
||||
#include <libxml/relaxng.h>
|
||||
|
||||
TIMER_ADD_CLIENT(xml_validation);
|
||||
|
||||
RelaxNGValidator::RelaxNGValidator() :
|
||||
m_Schema(NULL)
|
||||
{
|
||||
}
|
||||
|
||||
RelaxNGValidator::~RelaxNGValidator()
|
||||
{
|
||||
if (m_Schema)
|
||||
xmlRelaxNGFree(m_Schema);
|
||||
}
|
||||
|
||||
bool RelaxNGValidator::LoadGrammar(const std::string& grammar)
|
||||
{
|
||||
TIMER_ACCRUE(xml_validation);
|
||||
|
||||
debug_assert(m_Schema == NULL);
|
||||
|
||||
xmlRelaxNGParserCtxtPtr ctxt = xmlRelaxNGNewMemParserCtxt(grammar.c_str(), grammar.size());
|
||||
m_Schema = xmlRelaxNGParse(ctxt);
|
||||
xmlRelaxNGFreeParserCtxt(ctxt);
|
||||
|
||||
if (m_Schema == NULL)
|
||||
{
|
||||
LOGERROR(L"RelaxNGValidator: Failed to compile schema");
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool RelaxNGValidator::Validate(const std::wstring& filename, const std::wstring& document)
|
||||
{
|
||||
TIMER_ACCRUE(xml_validation);
|
||||
|
||||
if (!m_Schema)
|
||||
{
|
||||
LOGERROR(L"RelaxNGValidator: No grammar loaded");
|
||||
return false;
|
||||
}
|
||||
|
||||
std::string docutf8 = "<?xml version='1.0' encoding='utf-8'?>" + utf8_from_wstring(document);
|
||||
|
||||
xmlDocPtr doc = xmlReadMemory(docutf8.c_str(), docutf8.size(), utf8_from_wstring(filename).c_str(), NULL, XML_PARSE_NONET);
|
||||
if (doc == NULL)
|
||||
{
|
||||
LOGERROR(L"RelaxNGValidator: Failed to parse document");
|
||||
return false;
|
||||
}
|
||||
|
||||
xmlRelaxNGValidCtxtPtr ctxt = xmlRelaxNGNewValidCtxt(m_Schema);
|
||||
int ret = xmlRelaxNGValidateDoc(ctxt, doc);
|
||||
xmlRelaxNGFreeValidCtxt(ctxt);
|
||||
xmlFreeDoc(doc);
|
||||
|
||||
if (ret == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
else if (ret > 0)
|
||||
{
|
||||
LOGERROR(L"RelaxNGValidator: Validation failed");
|
||||
return false;
|
||||
}
|
||||
else
|
||||
{
|
||||
LOGERROR(L"RelaxNGValidator: Internal error %d", ret);
|
||||
return false;
|
||||
}
|
||||
}
|
38
source/ps/XML/RelaxNG.h
Normal file
38
source/ps/XML/RelaxNG.h
Normal file
@ -0,0 +1,38 @@
|
||||
/* Copyright (C) 2010 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#ifndef INCLUDED_RELAXNG
|
||||
#define INCLUDED_RELAXNG
|
||||
|
||||
typedef struct _xmlRelaxNG xmlRelaxNG;
|
||||
typedef xmlRelaxNG *xmlRelaxNGPtr;
|
||||
|
||||
class RelaxNGValidator
|
||||
{
|
||||
public:
|
||||
RelaxNGValidator();
|
||||
~RelaxNGValidator();
|
||||
|
||||
bool LoadGrammar(const std::string& grammar);
|
||||
|
||||
bool Validate(const std::wstring& filename, const std::wstring& document);
|
||||
|
||||
private:
|
||||
xmlRelaxNGPtr m_Schema;
|
||||
};
|
||||
|
||||
#endif // INCLUDED_RELAXNG
|
@ -33,9 +33,14 @@
|
||||
|
||||
static void errorHandler(void* UNUSED(userData), xmlErrorPtr error)
|
||||
{
|
||||
// Strip a trailing newline
|
||||
std::string message = error->message;
|
||||
if (message.length() > 0 && message[message.length()-1] == '\n')
|
||||
message.erase(message.length()-1);
|
||||
|
||||
LOG(CLogger::Error, LOG_CATEGORY, L"CXeromyces: Parse %ls: %hs:%d: %hs",
|
||||
error->level == XML_ERR_WARNING ? L"warning" : L"error",
|
||||
error->file, error->line, error->message);
|
||||
error->file, error->line, message.c_str());
|
||||
// TODO: The (non-fatal) warnings and errors don't get stored in the XMB,
|
||||
// so the caching is less transparent than it should be
|
||||
}
|
||||
|
89
source/ps/XML/tests/test_RelaxNG.h
Normal file
89
source/ps/XML/tests/test_RelaxNG.h
Normal file
@ -0,0 +1,89 @@
|
||||
/* Copyright (C) 2010 Wildfire Games.
|
||||
* This file is part of 0 A.D.
|
||||
*
|
||||
* 0 A.D. is free software: you can redistribute it and/or modify
|
||||
* it under the terms of the GNU General Public License as published by
|
||||
* the Free Software Foundation, either version 2 of the License, or
|
||||
* (at your option) any later version.
|
||||
*
|
||||
* 0 A.D. is distributed in the hope that it will be useful,
|
||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
* GNU General Public License for more details.
|
||||
*
|
||||
* You should have received a copy of the GNU General Public License
|
||||
* along with 0 A.D. If not, see <http://www.gnu.org/licenses/>.
|
||||
*/
|
||||
|
||||
#include "lib/self_test.h"
|
||||
|
||||
#include "ps/CLogger.h"
|
||||
#include "ps/XML/Xeromyces.h"
|
||||
#include "ps/XML/RelaxNG.h"
|
||||
|
||||
class TestRelaxNG : public CxxTest::TestSuite
|
||||
{
|
||||
public:
|
||||
void setUp()
|
||||
{
|
||||
CXeromyces::Startup();
|
||||
}
|
||||
|
||||
void tearDown()
|
||||
{
|
||||
CXeromyces::Terminate();
|
||||
}
|
||||
|
||||
void test_basic()
|
||||
{
|
||||
RelaxNGValidator v;
|
||||
TS_ASSERT(v.LoadGrammar("<element xmlns='http://relaxng.org/ns/structure/1.0' name='test'><empty/></element>"));
|
||||
|
||||
TS_ASSERT(v.Validate(L"doc", L"<test/>"));
|
||||
|
||||
{
|
||||
TestLogger logger;
|
||||
TS_ASSERT(!v.Validate(L"doc", L"<bogus/>"));
|
||||
TS_ASSERT_WSTR_CONTAINS(logger.GetOutput(), L"Parse error: doc:1: Expecting element test, got bogus");
|
||||
}
|
||||
|
||||
{
|
||||
TestLogger logger;
|
||||
TS_ASSERT(!v.Validate(L"doc", L"bogus"));
|
||||
TS_ASSERT_WSTR_CONTAINS(logger.GetOutput(), L"RelaxNGValidator: Failed to parse document");
|
||||
}
|
||||
|
||||
TS_ASSERT(v.Validate(L"doc", L"<test/>"));
|
||||
}
|
||||
|
||||
void test_interleave()
|
||||
{
|
||||
RelaxNGValidator v;
|
||||
TS_ASSERT(v.LoadGrammar("<element xmlns='http://relaxng.org/ns/structure/1.0' name='test'><interleave><empty/></interleave></element>"));
|
||||
// This currently (libxml2-2.7.7) leaks memory and makes Valgrind complain - see https://bugzilla.gnome.org/show_bug.cgi?id=615767
|
||||
}
|
||||
|
||||
void test_datatypes()
|
||||
{
|
||||
RelaxNGValidator v;
|
||||
TS_ASSERT(v.LoadGrammar("<element xmlns='http://relaxng.org/ns/structure/1.0' datatypeLibrary='http://www.w3.org/2001/XMLSchema-datatypes' name='test'><data type='decimal'><param name='minInclusive'>1.5</param></data></element>"));
|
||||
|
||||
TS_ASSERT(v.Validate(L"doc", L"<test>2.0</test>"));
|
||||
|
||||
TestLogger logger;
|
||||
|
||||
TS_ASSERT(!v.Validate(L"doc", L"<test>x</test>"));
|
||||
TS_ASSERT(!v.Validate(L"doc", L"<test>1.0</test>"));
|
||||
}
|
||||
|
||||
void test_broken_grammar()
|
||||
{
|
||||
RelaxNGValidator v;
|
||||
|
||||
TestLogger logger;
|
||||
TS_ASSERT(!v.LoadGrammar("whoops"));
|
||||
TS_ASSERT_WSTR_CONTAINS(logger.GetOutput(), L"RelaxNGValidator: Failed to compile schema");
|
||||
|
||||
TS_ASSERT(!v.Validate(L"doc", L"<test/>"));
|
||||
}
|
||||
};
|
@ -39,7 +39,6 @@ public:
|
||||
CSimulation2Impl(CUnitManager* unitManager, CTerrain* terrain) :
|
||||
m_SimContext(), m_ComponentManager(m_SimContext)
|
||||
{
|
||||
m_SimContext.m_ComponentManager = &m_ComponentManager;
|
||||
m_SimContext.m_UnitManager = unitManager;
|
||||
m_SimContext.m_Terrain = terrain;
|
||||
m_ComponentManager.LoadComponentTypes();
|
||||
|
@ -25,6 +25,7 @@
|
||||
#include "lib/utf8.h"
|
||||
#include "ps/CLogger.h"
|
||||
#include "ps/Filesystem.h"
|
||||
#include "ps/XML/RelaxNG.h"
|
||||
#include "ps/XML/Xeromyces.h"
|
||||
|
||||
static const wchar_t TEMPLATE_ROOT[] = L"simulation/templates/";
|
||||
@ -40,8 +41,11 @@ public:
|
||||
|
||||
DEFAULT_COMPONENT_ALLOCATOR(TemplateManager)
|
||||
|
||||
virtual void Init(const CSimContext& UNUSED(context), const CParamNode& UNUSED(paramNode))
|
||||
virtual void Init(const CSimContext& context, const CParamNode& UNUSED(paramNode))
|
||||
{
|
||||
m_Validator.LoadGrammar(context.GetComponentManager().GenerateSchema());
|
||||
// TODO: handle errors loading the grammar here?
|
||||
// TODO: support hotloading changes to the grammar
|
||||
}
|
||||
|
||||
virtual void Deinit(const CSimContext& UNUSED(context))
|
||||
@ -63,8 +67,10 @@ public:
|
||||
// template data before other components (like the tech components) have been deserialized
|
||||
}
|
||||
|
||||
virtual void Deserialize(const CSimContext& UNUSED(context), const CParamNode& UNUSED(paramNode), IDeserializer& deserialize)
|
||||
virtual void Deserialize(const CSimContext& context, const CParamNode& paramNode, IDeserializer& deserialize)
|
||||
{
|
||||
Init(context, paramNode);
|
||||
|
||||
u32 numEntities;
|
||||
deserialize.NumberU32_Unbounded(numEntities);
|
||||
for (u32 i = 0; i < numEntities; ++i)
|
||||
@ -104,12 +110,20 @@ public:
|
||||
virtual std::vector<std::wstring> FindAllTemplates();
|
||||
|
||||
private:
|
||||
// Entity template XML validator
|
||||
RelaxNGValidator m_Validator;
|
||||
|
||||
// Map from template name (XML filename or special |-separated string) to the most recently
|
||||
// loaded valid template data.
|
||||
// (Failed loads won't remove valid entries under the same name, so we behave more nicely
|
||||
// loaded non-broken template data. This includes files that will fail schema validation.
|
||||
// (Failed loads won't remove existing entries under the same name, so we behave more nicely
|
||||
// when hotloading broken files)
|
||||
std::map<std::wstring, CParamNode> m_TemplateFileData;
|
||||
|
||||
// Map from template name to schema validation status.
|
||||
// (Some files, e.g. inherited parent templates, may not be valid themselves but we still need to load
|
||||
// them and use them; we only reject invalid templates that were requested directly by GetTemplate/etc)
|
||||
std::map<std::wstring, bool> m_TemplateSchemaValidity;
|
||||
|
||||
// Remember the template used by each entity, so we can return them
|
||||
// again for deserialization.
|
||||
// TODO: should store player ID etc.
|
||||
@ -157,6 +171,13 @@ const CParamNode* CCmpTemplateManager::GetTemplate(std::wstring templateName)
|
||||
return NULL;
|
||||
}
|
||||
|
||||
// Compute validity, if it's not computed before
|
||||
if (m_TemplateSchemaValidity.find(templateName) == m_TemplateSchemaValidity.end())
|
||||
m_TemplateSchemaValidity[templateName] = m_Validator.Validate(templateName, m_TemplateFileData[templateName].ToXML());
|
||||
// Refuse to return invalid templates
|
||||
if (!m_TemplateSchemaValidity[templateName])
|
||||
return NULL;
|
||||
|
||||
const CParamNode& templateRoot = m_TemplateFileData[templateName].GetChild("Entity");
|
||||
if (!templateRoot.IsOk())
|
||||
{
|
||||
|
@ -36,6 +36,11 @@ public:
|
||||
|
||||
int32_t m_x;
|
||||
|
||||
static std::string GetSchema()
|
||||
{
|
||||
return "<ref name='anything'/>";
|
||||
}
|
||||
|
||||
virtual void Init(const CSimContext&, const CParamNode& paramNode)
|
||||
{
|
||||
if (paramNode.GetChild("x").IsOk())
|
||||
|
@ -59,7 +59,6 @@ public:
|
||||
{
|
||||
CSimContext context;
|
||||
CComponentManager componentManager(context, true);
|
||||
context.SetComponentManager(&componentManager);
|
||||
|
||||
ScriptTestSetup(componentManager.GetScriptInterface());
|
||||
|
||||
|
@ -29,9 +29,11 @@
|
||||
#include "ps/CLogger.h"
|
||||
#include "ps/Filesystem.h"
|
||||
|
||||
CComponentManager::CComponentManager(const CSimContext& context, bool skipScriptFunctions) :
|
||||
CComponentManager::CComponentManager(CSimContext& context, bool skipScriptFunctions) :
|
||||
m_NextScriptComponentTypeId(CID__LastNative), m_ScriptInterface("Engine"), m_SimContext(context), m_CurrentlyHotloading(false)
|
||||
{
|
||||
context.SetComponentManager(this);
|
||||
|
||||
m_ScriptInterface.SetCallbackData(static_cast<void*> (this));
|
||||
|
||||
// For component script tests, the test system sets up its own scripted implementation of
|
||||
|
@ -68,7 +68,7 @@ private:
|
||||
};
|
||||
|
||||
public:
|
||||
CComponentManager(const CSimContext&, bool skipScriptFunctions = false);
|
||||
CComponentManager(CSimContext&, bool skipScriptFunctions = false);
|
||||
~CComponentManager();
|
||||
|
||||
void LoadComponentTypes();
|
||||
|
@ -55,7 +55,6 @@ public:
|
||||
ComponentTestHelper() :
|
||||
m_Context(), m_ComponentManager(m_Context), m_Cmp(NULL)
|
||||
{
|
||||
m_Context.SetComponentManager(&m_ComponentManager);
|
||||
m_ComponentManager.LoadComponentTypes();
|
||||
}
|
||||
|
||||
|
@ -61,15 +61,15 @@ public:
|
||||
|
||||
const CParamNode* basic = tempMan->LoadTemplate(ent2, L"basic", -1);
|
||||
TS_ASSERT(basic != NULL);
|
||||
TS_ASSERT_WSTR_EQUALS(basic->ToXML(), L"<x>12345</x>");
|
||||
TS_ASSERT_WSTR_EQUALS(basic->ToXML(), L"<Test1A>12345</Test1A>");
|
||||
|
||||
const CParamNode* inherit2 = tempMan->LoadTemplate(ent2, L"inherit2", -1);
|
||||
TS_ASSERT(inherit2 != NULL);
|
||||
TS_ASSERT_WSTR_EQUALS(inherit2->ToXML(), L"<x a=\"a2\" b=\"b1\" c=\"c1\"><d>d2</d><e>e1</e><f>f1</f><g>g2</g></x>");
|
||||
TS_ASSERT_WSTR_EQUALS(inherit2->ToXML(), L"<Test1A a=\"a2\" b=\"b1\" c=\"c1\"><d>d2</d><e>e1</e><f>f1</f><g>g2</g></Test1A>");
|
||||
|
||||
const CParamNode* inherit1 = tempMan->LoadTemplate(ent2, L"inherit1", -1);
|
||||
TS_ASSERT(inherit1 != NULL);
|
||||
TS_ASSERT_WSTR_EQUALS(inherit1->ToXML(), L"<x a=\"a1\" b=\"b1\" c=\"c1\"><d>d1</d><e>e1</e><f>f1</f></x>");
|
||||
TS_ASSERT_WSTR_EQUALS(inherit1->ToXML(), L"<Test1A a=\"a1\" b=\"b1\" c=\"c1\"><d>d1</d><e>e1</e><f>f1</f></Test1A>");
|
||||
|
||||
const CParamNode* actor = tempMan->LoadTemplate(ent2, L"actor|example1", -1);
|
||||
TS_ASSERT(actor != NULL);
|
||||
@ -81,7 +81,7 @@ public:
|
||||
|
||||
const CParamNode* previewobstruct = tempMan->LoadTemplate(ent2, L"preview|unitobstruct", -1);
|
||||
TS_ASSERT(previewobstruct != NULL);
|
||||
TS_ASSERT_WSTR_EQUALS(previewobstruct->ToXML(), L"<Footprint><Circle radius=\"4\"></Circle></Footprint><Obstruction><Inactive></Inactive></Obstruction><Position><Altitude>0</Altitude><Anchor>upright</Anchor><Floating>false</Floating></Position><VisualActor><Actor>example</Actor></VisualActor>");
|
||||
TS_ASSERT_WSTR_EQUALS(previewobstruct->ToXML(), L"<Footprint><Circle radius=\"4\"></Circle><Height>1.0</Height></Footprint><Obstruction><Inactive></Inactive></Obstruction><Position><Altitude>0</Altitude><Anchor>upright</Anchor><Floating>false</Floating></Position><VisualActor><Actor>example</Actor></VisualActor>");
|
||||
|
||||
const CParamNode* previewactor = tempMan->LoadTemplate(ent2, L"preview|actor|example2", -1);
|
||||
TS_ASSERT(previewactor != NULL);
|
||||
@ -104,6 +104,10 @@ public:
|
||||
|
||||
TestLogger logger;
|
||||
|
||||
TS_ASSERT(tempMan->LoadTemplate(ent2, L"illformed", -1) == NULL);
|
||||
|
||||
TS_ASSERT(tempMan->LoadTemplate(ent2, L"invalid", -1) == NULL);
|
||||
|
||||
TS_ASSERT(tempMan->LoadTemplate(ent2, L"nonexistent", -1) == NULL);
|
||||
|
||||
TS_ASSERT(tempMan->LoadTemplate(ent2, L"inherit-loop", -1) == NULL);
|
||||
|
Loading…
Reference in New Issue
Block a user