diff --git a/.github/workflows/dotnet-desktop.yml b/.github/workflows/dotnet-desktop.yml
index af5c6bc9..3f373e51 100644
--- a/.github/workflows/dotnet-desktop.yml
+++ b/.github/workflows/dotnet-desktop.yml
@@ -47,3 +47,8 @@ jobs:
msbuild ./src/XmlNotepad.sln /p:Configuration=$env:Configuration "/p:Platform=Any CPU" /verbosity:minimal
env:
Configuration: ${{ matrix.configuration }}
+
+ - name: Run headless model regression tests
+ run: dotnet test .\src\Model.Tests\Model.Tests.csproj --configuration $env:Configuration --no-build --verbosity normal
+ env:
+ Configuration: ${{ matrix.configuration }}
diff --git a/src/Application/XmlDiffWrapper.cs b/src/Application/XmlDiffWrapper.cs
index 8defef3a..7442db8c 100644
--- a/src/Application/XmlDiffWrapper.cs
+++ b/src/Application/XmlDiffWrapper.cs
@@ -70,14 +70,14 @@ public void DoCompare(Form owner, XmlCache model, string otherXmlFile, XmlDiffOp
// diffView.Load call.
XmlDocument original = new XmlDocument();
XmlReaderSettings settings = model.GetReaderSettings();
- using (XmlReader reader = XmlReader.Create(filename, settings))
+ using (XmlReader reader = XmlHelpers.ReadDocument(filename, settings))
{
original.Load(reader);
}
XmlDocument doc = new XmlDocument();
settings = model.GetReaderSettings();
- using (XmlReader reader = XmlReader.Create(otherXmlFile, settings))
+ using (XmlReader reader = XmlHelpers.ReadDocument(otherXmlFile, settings))
{
doc.Load(reader);
}
@@ -111,10 +111,13 @@ public void DoCompare(Form owner, XmlCache model, string otherXmlFile, XmlDiffOp
using (XmlReader diffGram = XmlReader.Create(diffFile, settings))
{
XmlDiffView diffView = new XmlDiffView();
- using (var reader = new XmlTextReader(filename))
+ var location = new Uri(filename);
+ // XmlDiffView requires XmlTextReader; only the selected root uses the unguarded transport.
+ using (var input = (Stream)new XmlUrlResolver().GetEntity(location, null, typeof(Stream)))
+ using (var reader = new XmlTextReader(location.AbsoluteUri, input))
{
- var ignoreDtd = Settings.Instance.GetBoolean("IgnoreDTD");
- reader.DtdProcessing = ignoreDtd ? DtdProcessing.Ignore : DtdProcessing.Parse;
+ reader.DtdProcessing = settings.DtdProcessing;
+ reader.XmlResolver = new XmlResourceResolver(model.Settings.Resolver ?? new XmlUrlResolver());
diffView.Load(reader, diffGram);
using (TextWriter htmlWriter = new StreamWriter(tempFile, false, Encoding.UTF8))
{
diff --git a/src/Model.Tests/ExternalResourceTests.cs b/src/Model.Tests/ExternalResourceTests.cs
new file mode 100644
index 00000000..47929468
--- /dev/null
+++ b/src/Model.Tests/ExternalResourceTests.cs
@@ -0,0 +1,585 @@
+using Microsoft.VisualStudio.TestTools.UnitTesting;
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+using System.Net;
+using System.Text;
+using System.Threading.Tasks;
+using System.Xml;
+using System.Xml.Schema;
+using System.Xml.Xsl;
+using XmlNotepad;
+
+// Settings.Instance and the delayed-action dispatcher are process-global.
+[assembly: DoNotParallelize]
+
+namespace XmlNotepad.Model.Tests
+{
+ [TestClass]
+ public sealed class ExternalResourceTests : IServiceProvider
+ {
+ private const string NetworkSchemaLocation = @"\\mock-schema.invalid\schemas\schema.xsd";
+ private const string NetworkFileUri = "file://mock-schema.invalid/schemas/resource.xml";
+ private const string LocalSchema = "" +
+ "";
+ private MockSchemaSource _source = null!;
+ private TestProxyResolver _resolver = null!;
+ private Settings _settings = null!;
+ private SchemaCache _cache = null!;
+ private XmlCache _model = null!;
+ private string _documentFile = null!;
+
+ [TestInitialize]
+ public void Initialize()
+ {
+ _source = new MockSchemaSource();
+ _resolver = new TestProxyResolver(this, _source);
+ _settings = new Settings
+ {
+ StartupPath = AppContext.BaseDirectory,
+ Resolver = _resolver
+ };
+ _settings.SetDefaults();
+ var actions = new DelayedActions(action => action());
+ _settings.DelayedActions = actions;
+ _cache = new SchemaCache(this, _source);
+ _model = new XmlCache(this, _cache, actions);
+ _documentFile = Path.GetTempFileName();
+ File.Delete(_documentFile); // The in-memory input must not enable file watching.
+ }
+
+ [TestCleanup]
+ public void Cleanup()
+ {
+ _model?.Dispose();
+ if (_documentFile != null)
+ {
+ File.Delete(_documentFile);
+ }
+ _settings?.Dispose();
+ }
+
+ public object? GetService(Type serviceType)
+ {
+ return serviceType == typeof(Settings) ? _settings : null;
+ }
+
+ [TestMethod]
+ [DataRow("noNamespaceSchemaLocation", NetworkSchemaLocation)]
+ [DataRow("schemaLocation", NetworkSchemaLocation)]
+ [DataRow("noNamespaceSchemaLocation", NetworkFileUri)]
+ [DataRow("schemaLocation", NetworkFileUri)]
+ [DataRow("noNamespaceSchemaLocation", "file:///%5C%5Cmock-schema.invalid/schemas/schema.xsd")]
+ [DataRow("schemaLocation", "file:///%5C%5Cmock-schema.invalid/schemas/schema.xsd")]
+ public void OpeningDocumentDoesNotRequestNetworkSchema(string schemaAttribute, string schemaLocation)
+ {
+ LoadXml(CreateXml(schemaAttribute, schemaLocation));
+
+ Assert.IsEmpty(_source.Requests,
+ "Opening XML must not implicitly request a schema from a network share. " +
+ "Mock source requests: " + string.Join(", ", _source.Requests));
+ }
+
+ [TestMethod]
+ [DataRow("noNamespaceSchemaLocation", NetworkSchemaLocation)]
+ [DataRow("schemaLocation", NetworkSchemaLocation)]
+ [DataRow("noNamespaceSchemaLocation", NetworkFileUri)]
+ [DataRow("schemaLocation", NetworkFileUri)]
+ public void ValidatingDocumentDoesNotRequestNetworkSchema(string schemaAttribute, string schemaLocation)
+ {
+ var document = new XmlDocument { XmlResolver = _resolver };
+ document.LoadXml(CreateXml(schemaAttribute, schemaLocation));
+ _model.Load(document);
+
+ _model.ValidateModel(new ValidationErrorHandler());
+
+ Assert.IsEmpty(_source.Requests,
+ "Validation must not implicitly request a schema from a network share. " +
+ "Mock source requests: " + string.Join(", ", _source.Requests));
+ }
+
+ [TestMethod]
+ public void OpeningDocumentLoadsLocalSchemaFromMockSource()
+ {
+ var schemaUri = new Uri(new Uri(_documentFile), "schema.xsd");
+ _source.Documents.Add(schemaUri, LocalSchema);
+ LoadXml(CreateXml("noNamespaceSchemaLocation", "schema.xsd"));
+
+ Assert.HasCount(1, _source.Requests);
+ Assert.AreEqual(schemaUri, _source.Requests[0]);
+ Assert.AreEqual(1, _model.Document.Schemas.Count);
+ }
+
+ [TestMethod]
+ [DataRow("http://mock-schema.invalid/schema.xsd")]
+ [DataRow("https://mock-schema.invalid/schema.xsd")]
+ public void OpeningDocumentLoadsHttpSchemaFromMockSource(string location)
+ {
+ var schemaUri = new Uri(location);
+ _source.Documents.Add(schemaUri, LocalSchema);
+
+ LoadXml(CreateXml("noNamespaceSchemaLocation", location));
+
+ Assert.HasCount(1, _source.Requests);
+ Assert.AreEqual(schemaUri, _source.Requests[0]);
+ Assert.AreEqual(1, _model.Document.Schemas.Count);
+ }
+
+ [TestMethod]
+ [DataRow("include")]
+ [DataRow("import")]
+ public void OpeningDocumentDoesNotRequestNetworkSchemaDependency(string directive)
+ {
+ var schemaUri = new Uri(new Uri(_documentFile), "schema.xsd");
+ string targetNamespace = directive == "import" ? " namespace=\"urn:external\"" : "";
+ _source.Documents.Add(schemaUri,
+ "" +
+ "" +
+ "");
+
+ LoadXml(CreateXml("noNamespaceSchemaLocation", "schema.xsd"));
+
+ Assert.HasCount(1, _source.Requests,
+ "Only the local parent schema may reach the source. Requests: " +
+ string.Join(", ", _source.Requests));
+ Assert.AreEqual(schemaUri, _source.Requests[0]);
+ }
+
+ [TestMethod]
+ public void OpeningDocumentWithoutSchemaLocationDoesNotRequestSchema()
+ {
+ LoadXml("");
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow(false)]
+ [DataRow(true)]
+ public void ParsingDtdDoesNotRequestNetworkResource(bool externalSubset)
+ {
+ _settings["IgnoreDTD"] = false;
+ string declaration = externalSubset
+ ? ""
+ : "]>";
+ string xml = declaration + (externalSubset ? "" : "&external;");
+ using (var text = new StringReader(xml))
+ using (var reader = XmlHelpers.ReadXml(text, _resolver))
+ {
+ var document = new XmlDocument();
+ Assert.Throws(() => document.Load(reader));
+ }
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void ParsingDtdLoadsLocalEntityFromMockSource()
+ {
+ _settings["IgnoreDTD"] = false;
+ var entityUri = new Uri(new Uri(_documentFile), "entity.txt");
+ _source.Documents.Add(entityUri, "allowed");
+ string xml = "]>&external;";
+ using (var text = new StringReader(xml))
+ using (var reader = XmlHelpers.ReadXml(text, _resolver))
+ {
+ var document = new XmlDocument();
+ document.Load(reader);
+ Assert.AreEqual("allowed", document.InnerText);
+ }
+
+ Assert.HasCount(1, _source.Requests);
+ }
+
+ [TestMethod]
+ public void ExpandingIncludesDoesNotRequestNetworkResource()
+ {
+ LoadXml("" +
+ "");
+
+ Assert.Throws(() => _model.ExpandIncludes());
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void ExpandingIncludesLoadsLocalResourceFromMockSource()
+ {
+ var includeUri = new Uri(new Uri(_documentFile), "include.xml");
+ _source.Documents.Add(includeUri, "allowed");
+ LoadXml("" +
+ "");
+
+ _model.ExpandIncludes();
+
+ Assert.AreEqual("allowed", _model.Document.DocumentElement?.InnerText);
+ Assert.HasCount(1, _source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow("include")]
+ [DataRow("import")]
+ public void LoadingStylesheetDoesNotRequestNetworkResource(string directive)
+ {
+ string stylesheet = "" +
+ "";
+ using (var text = new StringReader(stylesheet))
+ using (var reader = XmlReader.Create(text))
+ {
+ var transform = new XslCompiledTransform();
+ Assert.Throws(() => transform.Load(reader, XsltSettings.Default, _resolver));
+ }
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void TransformingDoesNotRequestNetworkDocument()
+ {
+ var transform = CreateDocumentTransform(NetworkFileUri);
+ using (var text = new StringReader(""))
+ using (var reader = XmlReader.Create(text))
+ using (var output = new StringWriter())
+ using (var writer = XmlWriter.Create(output, transform.OutputSettings))
+ {
+ Assert.Throws(() => transform.Transform(reader, null, writer, _resolver));
+ }
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void TransformingLoadsLocalDocumentFromMockSource()
+ {
+ var documentUri = new Uri(new Uri(_documentFile), "external.xml");
+ _source.Documents.Add(documentUri, "allowed");
+ var transform = CreateDocumentTransform(documentUri.AbsoluteUri);
+ using (var text = new StringReader(""))
+ using (var reader = XmlReader.Create(text))
+ using (var output = new StringWriter())
+ {
+ using (var writer = XmlWriter.Create(output, transform.OutputSettings))
+ {
+ transform.Transform(reader, null, writer, _resolver);
+ }
+ StringAssert.Contains(output.ToString(), "allowed");
+ }
+
+ Assert.HasCount(1, _source.Requests);
+ }
+
+ [TestMethod]
+ public async Task ResolvingNetworkResourceAsyncDoesNotReachSource()
+ {
+ await Assert.ThrowsAsync(() =>
+ _resolver.GetEntityAsync(new Uri(NetworkFileUri), null, typeof(Stream)));
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow("file:////mock-schema.invalid/schemas/resource.xml")]
+ [DataRow("file:///%5C%5C%3F%5CUNC%5Cmock-schema.invalid%5Cschemas%5Cresource.xml")]
+ public void ResolvingNetworkFileAliasDoesNotReachSource(string location)
+ {
+ Assert.Throws(() =>
+ _resolver.GetEntity(new Uri(location), null, typeof(Stream)));
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow(DriveType.Network)]
+ [DataRow(DriveType.Unknown)]
+ [DataRow(DriveType.NoRootDirectory)]
+ public void ResolvingRestrictedDriveDoesNotReachSource(DriveType driveType)
+ {
+ var uri = new Uri(_documentFile);
+ _source.Documents.Add(uri, "");
+ var resolver = new ClassifiedDriveResolver(_source, driveType);
+
+ Assert.Throws(() => resolver.GetEntity(uri, null, typeof(Stream)));
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void RestrictedDriveIsClassifiedBeforeNormalizingShortNameTail()
+ {
+ var uri = new Uri(new Uri(_documentFile), "SHORT~1|invalid/schema.xsd");
+ var resolver = new ClassifiedDriveResolver(_source, DriveType.Network);
+
+ Assert.Throws(() => resolver.GetEntity(uri, null, typeof(Stream)));
+
+ Assert.HasCount(1, resolver.Roots);
+ Assert.AreEqual(Path.GetPathRoot(_documentFile), resolver.Roots[0]);
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void RootSuggestionsResolveDeferredImportsThroughSchemaResolver()
+ {
+ var childUri = new Uri("mock://schemas/child.xsd");
+ const string parentSchema =
+ "" +
+ "";
+ XmlSchema parent;
+ using (var text = new StringReader(parentSchema))
+ using (var reader = XmlReader.Create(text, new XmlReaderSettings(),
+ "https://mock-schema.invalid/parent.xsd"))
+ {
+ parent = XmlSchema.Read(reader, null)!;
+ }
+ parent.SourceUri = "https://mock-schema.invalid/parent.xsd";
+ _cache.Add(parent);
+ _source.Documents.Add(childUri,
+ "" +
+ "" +
+ "");
+ _source.Requests.Clear();
+
+ var roots = new List(_cache.GetPossibleTopLevelElements());
+
+ Assert.HasCount(1, roots);
+ Assert.AreEqual(new XmlQualifiedName("root", "urn:parent"), roots[0].QualifiedName);
+ Assert.HasCount(1, _source.Requests);
+ Assert.AreEqual(childUri, _source.Requests[0]);
+ }
+
+ [TestMethod]
+ public void LoadingResourceDocumentBlocksNetworkSource()
+ {
+ var uri = new Uri(NetworkFileUri);
+ _source.Documents.Add(uri, "");
+
+ Assert.Throws(() => XmlHelpers.LoadXmlResource(uri, _resolver));
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow("stylesheet.xsl")]
+ [DataRow("https://mock-schema.invalid/stylesheet.xsl")]
+ public void LoadingResourceDocumentUsesAllowedMockSource(string location)
+ {
+ var uri = new Uri(new Uri(_documentFile), location);
+ _source.Documents.Add(uri, "");
+
+ var document = XmlHelpers.LoadXmlResource(uri, _resolver);
+
+ Assert.AreEqual("html", document.DocumentElement?.GetAttribute("method"));
+ Assert.HasCount(1, _source.Requests);
+ }
+
+ [TestMethod]
+ [DataRow(NetworkSchemaLocation)]
+ [DataRow(NetworkFileUri)]
+ public void ReadingExplicitlySelectedNetworkDocumentDoesNotResolveItsRoot(string filename)
+ {
+ Uri? opened = null;
+ var input = new MemoryStream(Encoding.UTF8.GetBytes("selected"));
+ using (var reader = XmlHelpers.ReadDocument(filename, _model.GetReaderSettings(), location =>
+ {
+ opened = location;
+ return input;
+ }))
+ {
+ var document = new XmlDocument();
+ document.Load(reader);
+ Assert.AreEqual("selected", document.InnerText);
+ }
+
+ Assert.AreEqual(new Uri(filename), opened);
+ Assert.IsFalse(input.CanRead);
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void ReadingSelectedNetworkDocumentStillBlocksItsExternalEntities()
+ {
+ _settings["IgnoreDTD"] = false;
+ const string xml = "]>&external;";
+ using (var reader = XmlHelpers.ReadDocument(NetworkFileUri, _model.GetReaderSettings(),
+ location => new MemoryStream(Encoding.UTF8.GetBytes(xml))))
+ {
+ var document = new XmlDocument();
+ Assert.Throws(() => document.Load(reader));
+ }
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public async Task ResolvingMappedDriveAsyncDoesNotReachSource()
+ {
+ var uri = new Uri(_documentFile);
+ _source.Documents.Add(uri, "");
+ var resolver = new ClassifiedDriveResolver(_source, DriveType.Network);
+
+ await Assert.ThrowsAsync(() => resolver.GetEntityAsync(uri, null, typeof(Stream)));
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public async Task ResolvingLocalResourceAsyncUsesMockSource()
+ {
+ var uri = new Uri(_documentFile);
+ _source.Documents.Add(uri, "allowed");
+ var resolver = new ClassifiedDriveResolver(_source, DriveType.Fixed);
+ using (var stream = (Stream)await resolver.GetEntityAsync(uri, null, typeof(Stream)))
+ using (var reader = new StreamReader(stream))
+ {
+ Assert.AreEqual("allowed", await reader.ReadToEndAsync());
+ }
+
+ Assert.HasCount(1, _source.Requests);
+ }
+
+ [TestMethod]
+ public void ReaderGuardsCustomResolverBeforeReadingExternalEntity()
+ {
+ _settings["IgnoreDTD"] = false;
+ string xml = "]>&external;";
+ using (var text = new StringReader(xml))
+ using (var reader = XmlHelpers.ReadXml(text, _source))
+ {
+ var document = new XmlDocument();
+ Assert.Throws(() => document.Load(reader));
+ }
+
+ Assert.IsEmpty(_source.Requests);
+ }
+
+ [TestMethod]
+ public void LoadingSelectedLocalFilePreservesRelativeEntityBaseUri()
+ {
+ _settings["IgnoreDTD"] = false;
+ string filename = Path.GetTempFileName();
+ var entityUri = new Uri(new Uri(filename), "entity.txt");
+ _source.Documents.Add(entityUri, "allowed");
+ try
+ {
+ File.WriteAllText(filename,
+ "]>&external;");
+
+ _model.Load(filename);
+
+ Assert.AreEqual("allowed", _model.Document.DocumentElement?.InnerText);
+ Assert.HasCount(1, _source.Requests);
+ Assert.AreEqual(entityUri, _source.Requests[0]);
+ }
+ finally
+ {
+ _model.Clear();
+ File.Delete(filename);
+ }
+ }
+
+ private XslCompiledTransform CreateDocumentTransform(string location)
+ {
+ string stylesheet = "" +
+ "";
+ var transform = new XslCompiledTransform();
+ using (var text = new StringReader(stylesheet))
+ using (var reader = XmlReader.Create(text))
+ {
+ transform.Load(reader, new XsltSettings(true, false), _resolver);
+ }
+ return transform;
+ }
+
+ private void LoadXml(string xml)
+ {
+ using (var text = new StringReader(xml))
+ using (var reader = XmlReader.Create(text, _model.GetReaderSettings(), new Uri(_documentFile).AbsoluteUri))
+ {
+ _model.Load(reader, _documentFile);
+ }
+ Assert.AreEqual("root", _model.Document.DocumentElement?.LocalName);
+ }
+
+ private static string CreateXml(string schemaAttribute, string schemaLocation)
+ {
+ string namespaceAttribute = "";
+ if (schemaAttribute == "schemaLocation")
+ {
+ namespaceAttribute = " xmlns=\"urn:mock-schema\"";
+ schemaLocation = "urn:mock-schema " + schemaLocation;
+ }
+ return "";
+ }
+
+ private sealed class MockSchemaSource : XmlResolver
+ {
+ public List Requests { get; } = new List();
+ public Dictionary Documents { get; } = new Dictionary();
+
+ public override ICredentials? Credentials
+ {
+ set => throw new NotSupportedException("The mock source never uses credentials.");
+ }
+
+ public override object GetEntity(Uri absoluteUri, string? role, Type? ofObjectToReturn)
+ {
+ Requests.Add(absoluteUri);
+ if (ofObjectToReturn != null && ofObjectToReturn != typeof(Stream))
+ {
+ throw new NotSupportedException("The mock source only provides streams.");
+ }
+
+ if (!Documents.TryGetValue(absoluteUri, out string? document))
+ {
+ throw new XmlException("The mock source recorded the request without performing I/O.");
+ }
+ return new MemoryStream(Encoding.UTF8.GetBytes(document));
+ }
+
+ public override Task