Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions BannerlordModEditor.Common.Tests/Services/XsdSchemaProviderTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
using System;
using System.Collections.Generic;
using System.IO;
using Xunit;
using BannerlordModEditor.Common.Services;

namespace BannerlordModEditor.Common.Tests.Services
{
public class XsdSchemaProviderTests
{
private readonly XsdSchemaProvider _provider;

public XsdSchemaProviderTests()
{
_provider = new XsdSchemaProvider();
}

[Fact]
public void GetXsdPathForXml_ReturnsNullForNonExistentGameDirectory()
{
var result = _provider.GetXsdPathForXml("test.xml", "/nonexistent/path");
Assert.Null(result);
}

[Fact]
public void GetXsdPathForXml_ReturnsNullForEmptyPaths()
{
Assert.Null(_provider.GetXsdPathForXml("", ""));
Assert.Null(_provider.GetXsdPathForXml(null!, null!));
}

[Fact]
public void GetXsdPathForXml_FindsXsdInXmlSchemasDirectory()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
var xsdDir = Path.Combine(tempDir, "XmlSchemas");
Directory.CreateDirectory(xsdDir);

try
{
var xsdPath = Path.Combine(xsdDir, "attributes.xsd");
File.WriteAllText(xsdPath, "<xs:schema xmlns:xs=\"http://www.w3.org/2001/XMLSchema\"></xs:schema>");

var result = _provider.GetXsdPathForXml("attributes.xml", tempDir);
Assert.Equal(xsdPath, result);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public void GetAllXsdFiles_ReturnsEmptyForNonExistentDirectory()
{
var result = _provider.GetAllXsdFiles("/nonexistent/path");
Assert.Empty(result);
}

[Fact]
public void GetAllXsdFiles_FindsAllXsdFiles()
{
var tempDir = Path.Combine(Path.GetTempPath(), Guid.NewGuid().ToString());
var xsdDir = Path.Combine(tempDir, "XmlSchemas");
Directory.CreateDirectory(xsdDir);

try
{
File.WriteAllText(Path.Combine(xsdDir, "file1.xsd"), "");
File.WriteAllText(Path.Combine(xsdDir, "file2.xsd"), "");

var result = _provider.GetAllXsdFiles(tempDir);
Assert.Equal(2, result.Count);
}
finally
{
Directory.Delete(tempDir, true);
}
}

[Fact]
public void LoadSchemaSet_ReturnsEmptyForNonExistentFile()
{
var result = _provider.LoadSchemaSet("/nonexistent/schema.xsd");
Assert.NotNull(result);
Assert.Equal(0, result.Count);
}

[Fact]
public void LoadSchemaSet_LoadsValidXsd()
{
var tempFile = Path.GetTempFileName();
var xsd = @"<?xml version=""1.0"" encoding=""utf-8""?>
<xs:schema xmlns:xs=""http://www.w3.org/2001/XMLSchema"">
<xs:element name=""Test"">
<xs:complexType>
<xs:sequence>
<xs:element name=""Name"" type=""xs:string""/>
</xs:sequence>
</xs:complexType>
</xs:element>
</xs:schema>";

try
{
File.WriteAllText(tempFile, xsd);

var result = _provider.LoadSchemaSet(tempFile);
Assert.NotNull(result);
Assert.Equal(1, result.Count);
}
finally
{
File.Delete(tempFile);
}
}

[Fact]
public void ValidateXmlAgainstXsd_ReturnsFalseForNonExistentXml()
{
var result = _provider.ValidateXmlAgainstXsd("/nonexistent.xml", "/schema.xsd", out var errors);
Assert.False(result);
Assert.Contains("XML file not found", errors[0]);
}

[Fact]
public void ValidateXmlAgainstXsd_ReturnsFalseForNonExistentXsd()
{
var tempXml = Path.GetTempFileName();
File.WriteAllText(tempXml, "<Test></Test>");

try
{
var result = _provider.ValidateXmlAgainstXsd(tempXml, "/nonexistent.xsd", out var errors);
Assert.False(result);
Assert.Contains("XSD schema not found", errors[0]);
}
finally
{
File.Delete(tempXml);
}
}
}
}
148 changes: 148 additions & 0 deletions BannerlordModEditor.Common/Services/XsdSchemaProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Xml;
using System.Xml.Schema;

namespace BannerlordModEditor.Common.Services
{
public interface IXsdSchemaProvider
{
string? GetXsdPathForXml(string xmlFilePath, string gameDirectory);
List<string> GetAllXsdFiles(string gameDirectory);
XmlSchemaSet LoadSchemaSet(string xsdPath);
bool ValidateXmlAgainstXsd(string xmlFilePath, string xsdPath, out List<string> errors);
}

public class XsdSchemaProvider : IXsdSchemaProvider
{
private const string XsdDirectoryRelativePath = "XmlSchemas";
private const string ModuleDataRelativePath = "Modules";

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ModuleDataRelativePath is declared but not used anywhere in this file. Removing it avoids dead code and reduces confusion about intended search paths.

Suggested change
private const string ModuleDataRelativePath = "Modules";

Copilot uses AI. Check for mistakes.
private const string NativeModuleRelativePath = "Modules\\Native\\ModuleData";

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NativeModuleRelativePath uses Windows backslashes. When combined on non-Windows platforms, the embedded \ becomes part of the directory name, causing schema discovery/scanning to fail. Define the path using Path.Combine("Modules", "Native", "ModuleData") (or store segments and combine) so it’s platform-correct.

Suggested change
private const string NativeModuleRelativePath = "Modules\\Native\\ModuleData";
private static readonly string NativeModuleRelativePath = Path.Combine("Modules", "Native", "ModuleData");

Copilot uses AI. Check for mistakes.

public string? GetXsdPathForXml(string xmlFilePath, string gameDirectory)
{
if (string.IsNullOrEmpty(xmlFilePath) || string.IsNullOrEmpty(gameDirectory))
return null;

var xmlFileName = Path.GetFileNameWithoutExtension(xmlFilePath);

var xsdPaths = new[]
{
Path.Combine(gameDirectory, XsdDirectoryRelativePath, $"{xmlFileName}.xsd"),
Path.Combine(gameDirectory, XsdDirectoryRelativePath, "Native", $"{xmlFileName}.xsd"),
Path.Combine(gameDirectory, NativeModuleRelativePath, $"{xmlFileName}.xsd")

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NativeModuleRelativePath uses Windows backslashes. When combined on non-Windows platforms, the embedded \ becomes part of the directory name, causing schema discovery/scanning to fail. Define the path using Path.Combine("Modules", "Native", "ModuleData") (or store segments and combine) so it’s platform-correct.

Copilot uses AI. Check for mistakes.
};

foreach (var xsdPath in xsdPaths)
{
if (File.Exists(xsdPath))
return xsdPath;
}

return null;
}

public List<string> GetAllXsdFiles(string gameDirectory)
{
var xsdFiles = new List<string>();

if (string.IsNullOrEmpty(gameDirectory) || !Directory.Exists(gameDirectory))
return xsdFiles;

var searchPaths = new[]
{
Path.Combine(gameDirectory, XsdDirectoryRelativePath),
Path.Combine(gameDirectory, NativeModuleRelativePath)

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

NativeModuleRelativePath uses Windows backslashes. When combined on non-Windows platforms, the embedded \ becomes part of the directory name, causing schema discovery/scanning to fail. Define the path using Path.Combine("Modules", "Native", "ModuleData") (or store segments and combine) so it’s platform-correct.

Copilot uses AI. Check for mistakes.
};

foreach (var searchPath in searchPaths)
{
if (Directory.Exists(searchPath))
{
try
{
xsdFiles.AddRange(Directory.GetFiles(searchPath, "*.xsd", SearchOption.AllDirectories));
}
catch
{
}
Comment on lines +64 to +70

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Swallowing all exceptions here can silently hide permission issues, path-too-long errors, or IO failures and make schema discovery non-deterministic in production. Catch specific exceptions (e.g., UnauthorizedAccessException, IOException) and either (a) surface them via an error list/logging, or (b) at least document why ignoring is safe and expected.

Copilot uses AI. Check for mistakes.
}
}

return xsdFiles.Distinct().ToList();
}

public XmlSchemaSet LoadSchemaSet(string xsdPath)
{
var schemaSet = new XmlSchemaSet();

if (!File.Exists(xsdPath))
return schemaSet;

try
{
using var stream = new FileStream(xsdPath, FileMode.Open, FileAccess.Read);
var schema = XmlSchema.Read(stream, null);
if (schema != null)
{
schemaSet.Add(schema);
}
}
Comment on lines +77 to +92

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

XmlSchemaSet can resolve <xs:include>/<xs:import> via an XmlResolver (default may allow external fetches / local file access depending on runtime), which is risky if schemas are not fully trusted. Consider setting schemaSet.XmlResolver = null (or a constrained resolver) and also ensure the XmlReaderSettings.XmlResolver is null during validation to prevent external entity/resource resolution.

Copilot uses AI. Check for mistakes.
catch (Exception ex)
{
throw new InvalidOperationException($"Failed to load XSD schema from {xsdPath}: {ex.Message}", ex);
}

return schemaSet;
}

public bool ValidateXmlAgainstXsd(string xmlFilePath, string xsdPath, out List<string> errors)
{
errors = new List<string>();

if (!File.Exists(xmlFilePath))
{
errors.Add($"XML file not found: {xmlFilePath}");
return false;
}

if (!File.Exists(xsdPath))
{
errors.Add($"XSD schema not found: {xsdPath}");
return false;
}
Comment on lines +101 to +115

Copilot AI Mar 20, 2026

Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tests currently cover missing XML/XSD paths, but don’t verify the core behavior of validating a real XML against a real XSD (both a passing case returning true and a failing case that returns false with populated validation errors). Adding those tests would cover the main feature this method introduces.

Copilot uses AI. Check for mistakes.

try
{
var schemaSet = LoadSchemaSet(xsdPath);
var validationErrors = new List<string>();
var settings = new XmlReaderSettings
{
ValidationType = ValidationType.Schema,
Schemas = schemaSet
};

settings.ValidationEventHandler += (sender, e) =>
{
var severity = e.Severity == XmlSeverityType.Error ? "Error" : "Warning";
validationErrors.Add($"{severity}: {e.Message}");
};

using var reader = XmlReader.Create(xmlFilePath, settings);
while (reader.Read())
{
}

errors = validationErrors;
return errors.Count == 0;
}
catch (Exception ex)
{
errors.Add($"Validation error: {ex.Message}");
return false;
}
}
}
}
Loading