diff --git a/README.md b/README.md index 7fbf263ba..8f24aa60a 100644 --- a/README.md +++ b/README.md @@ -25,6 +25,7 @@ project [SysML2.NET.Extensions](https://www.nuget.org/packages/SysML2.NET.Extensions) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.Extensions) [SysML2.NET.Serializer.Json](https://www.nuget.org/packages/SysML2.NET.Serializer.Json) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.Serializer.Json) [SysML2.NET.Serializer.MessagePack](https://www.nuget.org/packages/SysML2.NET.Serializer.MessagePack) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.Serializer.MessagePack) +[SysML2.NET.Kpar](https://www.nuget.org/packages/SysML2.NET.Kpar) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.Kpar) [SysML2.NET.REST](https://www.nuget.org/packages/SysML2.NET.REST) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.REST) [SysML2.NET.DAL](https://www.nuget.org/packages/SysML2.NET.DAL) | ![NuGet Version](https://img.shields.io/nuget/v/SysML2.NET.DAL) diff --git a/Resources/sysml.library.kpar/Kernel_Data_Type_Library-1.0.0.kpar b/Resources/sysml.library.kpar/Kernel_Data_Type_Library-1.0.0.kpar new file mode 100644 index 000000000..c944f3ae1 Binary files /dev/null and b/Resources/sysml.library.kpar/Kernel_Data_Type_Library-1.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/Kernel_Function_Library-1.0.0.kpar b/Resources/sysml.library.kpar/Kernel_Function_Library-1.0.0.kpar new file mode 100644 index 000000000..beaf224a0 Binary files /dev/null and b/Resources/sysml.library.kpar/Kernel_Function_Library-1.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/Kernel_Semantic_Library-1.0.0.kpar b/Resources/sysml.library.kpar/Kernel_Semantic_Library-1.0.0.kpar new file mode 100644 index 000000000..ef6c79c6c Binary files /dev/null and b/Resources/sysml.library.kpar/Kernel_Semantic_Library-1.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Analysis_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Analysis_Library-2.0.0.kpar new file mode 100644 index 000000000..fcdf17b76 Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Analysis_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Cause_and_Effect_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Cause_and_Effect_Library-2.0.0.kpar new file mode 100644 index 000000000..75da9ef1d Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Cause_and_Effect_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Geometry_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Geometry_Library-2.0.0.kpar new file mode 100644 index 000000000..1b8438615 Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Geometry_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Metadata_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Metadata_Library-2.0.0.kpar new file mode 100644 index 000000000..31d4667fa Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Metadata_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Quantities_and_Units_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Quantities_and_Units_Library-2.0.0.kpar new file mode 100644 index 000000000..17f566bb6 Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Quantities_and_Units_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Requirement_Derivation_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Requirement_Derivation_Library-2.0.0.kpar new file mode 100644 index 000000000..cd1ba93ba Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Requirement_Derivation_Library-2.0.0.kpar differ diff --git a/Resources/sysml.library.kpar/SysML_Systems_Library-2.0.0.kpar b/Resources/sysml.library.kpar/SysML_Systems_Library-2.0.0.kpar new file mode 100644 index 000000000..5af313598 Binary files /dev/null and b/Resources/sysml.library.kpar/SysML_Systems_Library-2.0.0.kpar differ diff --git a/SysML2.NET.Extensions.Tests/ModelInterchange/ArchiveExtensionsTestFixture.cs b/SysML2.NET.Extensions.Tests/ModelInterchange/ArchiveExtensionsTestFixture.cs new file mode 100644 index 000000000..e172b419a --- /dev/null +++ b/SysML2.NET.Extensions.Tests/ModelInterchange/ArchiveExtensionsTestFixture.cs @@ -0,0 +1,354 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------- + +namespace SysML2.NET.ModelInterchange.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + using NUnit.Framework; + + using SysML2.NET.Extensions.ModelInterchange; + + [TestFixture] + public class ArchiveExtensionsTestFixture + { + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_throws_when_archive_is_null() + { + Assert.That(() => + { + ArchiveExtensions.TryGetModelEntryByIndexKey(null, "Base", out _); + }, Throws.ArgumentNullException); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Verify_that_TryGetModelEntryByIndexKey_throws_when_indexKey_is_invalid(string indexKey) + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata { Index = new Dictionary() }, + Models = Array.Empty() + }; + + Assert.That(() => + { + archive.TryGetModelEntryByIndexKey(indexKey, out _); + }, Throws.ArgumentException); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_returns_false_when_metadata_index_is_missing() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata { Index = null }, + Models = Array.Empty() + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.False); + Assert.That(entry, Is.Null); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_returns_false_when_key_is_missing() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Other"] = "Other.kerml" + } + }, + Models = new[] + { + CreateModelEntry("Other.kerml") + } + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.False); + Assert.That(entry, Is.Null); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_returns_false_when_path_is_null_or_whitespace() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = " " + } + }, + Models = new[] + { + CreateModelEntry("Base.kerml") + } + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.False); + Assert.That(entry, Is.Null); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_returns_false_when_model_is_not_present() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = "Base.kerml" + } + }, + Models = new[] + { + CreateModelEntry("Other.kerml") + } + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.False); + Assert.That(entry, Is.Null); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_returns_true_and_sets_entry_when_found() + { + var expected = CreateModelEntry("Base.kerml"); + + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = "Base.kerml" + } + }, + Models = new[] + { + expected, + CreateModelEntry("Other.kerml") + } + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.True); + Assert.That(entry, Is.SameAs(expected)); + } + + [Test] + public void Verify_that_TryGetModelEntryByIndexKey_matches_using_normalized_paths() + { + // Index uses backslashes; model entry uses forward slashes. + var expected = CreateModelEntry("folder/Base.kerml"); + + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = @"folder\Base.kerml" + } + }, + Models = new[] + { + expected + } + }; + + var ok = archive.TryGetModelEntryByIndexKey("Base", out var entry); + + Assert.That(ok, Is.True); + Assert.That(entry, Is.SameAs(expected)); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_throws_when_archive_is_null() + { + Assert.That(() => ArchiveExtensions.GetModelEntryByIndexKey(null, "Base"), Throws.ArgumentNullException); + } + + [TestCase(null)] + [TestCase("")] + [TestCase(" ")] + public void Verify_that_GetModelEntryByIndexKey_throws_when_indexKey_is_invalid(string indexKey) + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata { Index = new Dictionary() }, + Models = Array.Empty() + }; + + Assert.That(() => archive.GetModelEntryByIndexKey(indexKey), Throws.ArgumentException); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_throws_when_metadata_index_is_not_available() + { + var archive = new Archive + { + Metadata = null, + Models = Array.Empty() + }; + + Assert.That(() => archive.GetModelEntryByIndexKey("Base"), Throws.InvalidOperationException); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_throws_when_key_is_not_found() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Other"] = "Other.kerml" + } + }, + Models = new[] { CreateModelEntry("Other.kerml") } + }; + + Assert.That(() => archive.GetModelEntryByIndexKey("Base"), Throws.TypeOf()); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_throws_when_index_entry_path_is_null_or_whitespace() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = " " + } + }, + Models = new[] { CreateModelEntry("Base.kerml") } + }; + + Assert.That(() => archive.GetModelEntryByIndexKey("Base"), Throws.TypeOf()); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_throws_when_model_entry_is_missing() + { + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = "Base.kerml" + } + }, + Models = new[] { CreateModelEntry("Other.kerml") } + }; + + Assert.That(() => archive.GetModelEntryByIndexKey("Base"), Throws.TypeOf()); + } + + [Test] + public void Verify_that_GetModelEntryByIndexKey_returns_entry_when_found() + { + var expected = CreateModelEntry("Base.kerml"); + + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = "Base.kerml" + } + }, + Models = new[] { expected } + }; + + var entry = archive.GetModelEntryByIndexKey("Base"); + + Assert.That(entry, Is.SameAs(expected)); + } + + [Test] + public async Task Verify_that_OpenModelByIndexKeyAsync_opens_stream_from_entry() + { + var expectedBytes = Encoding.UTF8.GetBytes("hello kerml"); + + var entry = new ModelEntry + { + Path = "Base.kerml", + ContentType = "text/plain", + OpenReadAsync = _ => new ValueTask(new MemoryStream(expectedBytes, writable: false)) + }; + + var archive = new Archive + { + Metadata = new InterchangeProjectMetadata + { + Index = new Dictionary(StringComparer.Ordinal) + { + ["Base"] = "Base.kerml" + } + }, + Models = new[] { entry } + }; + + await using var stream = await archive.OpenModelByIndexKeyAsync("Base", CancellationToken.None); + + Assert.That(stream, Is.Not.Null); + using var ms = new MemoryStream(); + await stream.CopyToAsync(ms); + Assert.That(ms.ToArray(), Is.EqualTo(expectedBytes)); + } + + private static ModelEntry CreateModelEntry(string path) + { + return new ModelEntry + { + Path = path, + ContentType = "text/plain", + OpenReadAsync = _ => new ValueTask(new MemoryStream(Array.Empty(), writable: false)) + }; + } + } +} diff --git a/SysML2.NET.Extensions.Tests/ModelInterchange/ChecksumKindProviderTestFixture.cs b/SysML2.NET.Extensions.Tests/ModelInterchange/ChecksumKindProviderTestFixture.cs new file mode 100644 index 000000000..2e0e457dc --- /dev/null +++ b/SysML2.NET.Extensions.Tests/ModelInterchange/ChecksumKindProviderTestFixture.cs @@ -0,0 +1,232 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Extensions.Tests.Core.ModelInterchange +{ + using System; + using System.Buffers; + using System.Linq; + using System.Text; + + using NUnit.Framework; + + using SysML2.NET.ModelInterchange; + + [TestFixture] + public class ChecksumKindProviderTestFixture + { + [Test] + public void Verify_that_on_Parse_when_value_is_null_then_ArgumentNullException_is_thrown() + { + Assert.That(() => + { + ReadOnlySpan value = null; + ChecksumKindProvider.Parse(value); + }, Throws.ArgumentNullException); + + Assert.That(() => + { + ReadOnlySpan value = null; + ChecksumKindProvider.Parse(value); + }, Throws.ArgumentNullException); + } + + [TestCase(ChecksumKind.SHA1, "SHA1")] + [TestCase(ChecksumKind.SHA224, "SHA224")] + [TestCase(ChecksumKind.SHA256, "SHA256")] + [TestCase(ChecksumKind.SHA384, "SHA-384")] + [TestCase(ChecksumKind.SHA3256, "SHA3-256")] + [TestCase(ChecksumKind.SHA3384, "SHA3-384")] + [TestCase(ChecksumKind.SHA3512, "SHA3-512")] + [TestCase(ChecksumKind.BLAKE2b256, "BLAKE2b-256")] + [TestCase(ChecksumKind.BLAKE2b384, "BLAKE2b-384")] + [TestCase(ChecksumKind.BLAKE2b512, "BLAKE2b-512")] + [TestCase(ChecksumKind.BLAKE3, "BLAKE3")] + [TestCase(ChecksumKind.MD2, "MD2")] + [TestCase(ChecksumKind.MD4, "MD4")] + [TestCase(ChecksumKind.MD5, "MD5")] + [TestCase(ChecksumKind.MD6, "MD6")] + [TestCase(ChecksumKind.ADLER32, "ADLER32")] + public void Verify_that_Parse_charSpan_parses_known_values_case_insensitively(ChecksumKind expected, string token) + { + // Upper + Assert.That(ChecksumKindProvider.Parse(token.AsSpan()), Is.EqualTo(expected)); + + // Lower + Assert.That(ChecksumKindProvider.Parse(token.ToLowerInvariant().AsSpan()), Is.EqualTo(expected)); + + // Mixed (simple mixed variant) + var mixed = token.Length > 1 + ? char.ToLowerInvariant(token[0]) + token.Substring(1).ToUpperInvariant() + : token; + Assert.That(ChecksumKindProvider.Parse(mixed.AsSpan()), Is.EqualTo(expected)); + } + + [TestCase(ChecksumKind.SHA1, "SHA1")] + [TestCase(ChecksumKind.SHA224, "SHA224")] + [TestCase(ChecksumKind.SHA256, "SHA256")] + [TestCase(ChecksumKind.SHA384, "SHA-384")] + [TestCase(ChecksumKind.SHA3256, "SHA3-256")] + [TestCase(ChecksumKind.SHA3384, "SHA3-384")] + [TestCase(ChecksumKind.SHA3512, "SHA3-512")] + [TestCase(ChecksumKind.BLAKE2b256, "BLAKE2b-256")] + [TestCase(ChecksumKind.BLAKE2b384, "BLAKE2b-384")] + [TestCase(ChecksumKind.BLAKE2b512, "BLAKE2b-512")] + [TestCase(ChecksumKind.BLAKE3, "BLAKE3")] + [TestCase(ChecksumKind.MD2, "MD2")] + [TestCase(ChecksumKind.MD4, "MD4")] + [TestCase(ChecksumKind.MD5, "MD5")] + [TestCase(ChecksumKind.MD6, "MD6")] + [TestCase(ChecksumKind.ADLER32, "ADLER32")] + public void Verify_that_Parse_utf8Span_parses_known_values_case_sensitively(ChecksumKind expected, string token) + { + // Note: this overload uses SequenceEqual against uppercase literals. + var utf8 = Encoding.UTF8.GetBytes(token); + Assert.That(ChecksumKindProvider.Parse(utf8.AsSpan()), Is.EqualTo(expected)); + } + + [TestCase(ChecksumKind.SHA1, "SHA1")] + [TestCase(ChecksumKind.SHA224, "SHA224")] + [TestCase(ChecksumKind.SHA256, "SHA256")] + [TestCase(ChecksumKind.SHA384, "SHA-384")] + [TestCase(ChecksumKind.SHA3256, "SHA3-256")] + [TestCase(ChecksumKind.SHA3384, "SHA3-384")] + [TestCase(ChecksumKind.SHA3512, "SHA3-512")] + [TestCase(ChecksumKind.BLAKE2b256, "BLAKE2b-256")] + [TestCase(ChecksumKind.BLAKE2b384, "BLAKE2b-384")] + [TestCase(ChecksumKind.BLAKE2b512, "BLAKE2b-512")] + [TestCase(ChecksumKind.BLAKE3, "BLAKE3")] + [TestCase(ChecksumKind.MD2, "MD2")] + [TestCase(ChecksumKind.MD4, "MD4")] + [TestCase(ChecksumKind.MD5, "MD5")] + [TestCase(ChecksumKind.MD6, "MD6")] + [TestCase(ChecksumKind.ADLER32, "ADLER32")] + public void Verify_that_Parse_readonlySequence_parses_single_segment(ChecksumKind expected, string token) + { + var bytes = Encoding.UTF8.GetBytes(token); + var sequence = new ReadOnlySequence(bytes); + Assert.That(ChecksumKindProvider.Parse(sequence), Is.EqualTo(expected)); + } + + [Test] + public void Verify_that_Parse_readonlySequence_parses_multi_segment_using_stackalloc_copy() + { + // Multi-segment "SHA256" -> "SHA" + "256" + var seg1 = Encoding.UTF8.GetBytes("SHA"); + var seg2 = Encoding.UTF8.GetBytes("256"); + + var first = new BufferSegment(seg1); + var last = first.Append(seg2); + + var sequence = new ReadOnlySequence(first, 0, last, last.Memory.Length); + + Assert.That(sequence.IsSingleSegment, Is.False); + Assert.That(ChecksumKindProvider.Parse(sequence), Is.EqualTo(ChecksumKind.SHA256)); + } + + [Test] + public void Verify_that_Parse_readonlySequence_throws_when_length_exceeds_16() + { + var bytes = Enumerable.Repeat((byte)'A', 17).ToArray(); + var sequence = new ReadOnlySequence(bytes); + + var ex = Assert.Throws(() => ChecksumKindProvider.Parse(sequence)); + Assert.That(ex!.ParamName, Is.EqualTo("value")); + Assert.That(ex.Message, Does.Contain("'AAAAAAAAAAAAAAAAA' is not a valid ChecksumKind (Parameter 'value')")); + } + + [Test] + public void Verify_that_Parse_charSpan_throws_for_unknown_value() + { + var ex = Assert.Throws(() => ChecksumKindProvider.Parse("NOPE".AsSpan())); + Assert.That(ex!.ParamName, Is.EqualTo("value")); + Assert.That(ex.Message, Does.Contain("is not a valid ChecksumKind")); + } + + [Test] + public void Verify_that_Parse_utf8Span_throws_for_unknown_value_and_message_contains_decoded_value() + { + var bytes = Encoding.UTF8.GetBytes("NOPE"); + var ex = Assert.Throws(() => ChecksumKindProvider.Parse(bytes.AsSpan())); + Assert.That(ex!.ParamName, Is.EqualTo("value")); + Assert.That(ex.Message, Does.Contain("NOPE")); + Assert.That(ex.Message, Does.Contain("is not a valid ChecksumKind")); + } + + [TestCase(ChecksumKind.SHA1, "SHA1")] + [TestCase(ChecksumKind.SHA224, "SHA224")] + [TestCase(ChecksumKind.SHA256, "SHA256")] + [TestCase(ChecksumKind.SHA384, "SHA-384")] + [TestCase(ChecksumKind.SHA3256, "SHA3-256")] + [TestCase(ChecksumKind.SHA3384, "SHA3-384")] + [TestCase(ChecksumKind.SHA3512, "SHA3-512")] + [TestCase(ChecksumKind.BLAKE2b256, "BLAKE2b-256")] + [TestCase(ChecksumKind.BLAKE2b384, "BLAKE2b-384")] + [TestCase(ChecksumKind.BLAKE2b512, "BLAKE2b-512")] + [TestCase(ChecksumKind.BLAKE3, "BLAKE3")] + [TestCase(ChecksumKind.MD2, "MD2")] + [TestCase(ChecksumKind.MD4, "MD4")] + [TestCase(ChecksumKind.MD5, "MD5")] + [TestCase(ChecksumKind.MD6, "MD6")] + [TestCase(ChecksumKind.ADLER32, "ADLER32")] + public void Verify_that_ToUtf8LowerBytes_returns_expected_token_and_roundtrips_via_Parse_utf8Span( + ChecksumKind kind, + string expectedToken) + { + var bytes = ChecksumKindProvider.ToUtf8LowerBytes(kind); + + // Verify exact bytes content + Assert.That(Encoding.UTF8.GetString(bytes), Is.EqualTo(expectedToken)); + + // Round-trip via Parse(ReadOnlySpan) + Assert.That(ChecksumKindProvider.Parse(bytes), Is.EqualTo(kind)); + } + + [Test] + public void Verify_that_ToUtf8LowerBytes_throws_for_undefined_enum_value() + { + // Pick a clearly undefined value + var invalid = (ChecksumKind)123456; + Assert.Throws(() => ChecksumKindProvider.ToUtf8LowerBytes(invalid)); + } + + /// + /// Minimal ReadOnlySequence segment helper to build multi-segment sequences. + /// + private sealed class BufferSegment : ReadOnlySequenceSegment + { + public BufferSegment(ReadOnlyMemory memory) + { + Memory = memory; + } + + public BufferSegment Append(ReadOnlyMemory memory) + { + var segment = new BufferSegment(memory) + { + RunningIndex = RunningIndex + Memory.Length + }; + + Next = segment; + return segment; + } + } + } +} diff --git a/SysML2.NET.Extensions.Tests/Utilities/StreamExtensionsTestFixture.cs b/SysML2.NET.Extensions.Tests/Utilities/StreamExtensionsTestFixture.cs new file mode 100644 index 000000000..45d426e3c --- /dev/null +++ b/SysML2.NET.Extensions.Tests/Utilities/StreamExtensionsTestFixture.cs @@ -0,0 +1,221 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Tests.Extensions.Utilities +{ + using System; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + using NUnit.Framework; + + using SysML2.NET.Extensions.Utilities; + + /// + /// Unit tests for . + /// + [TestFixture] + public sealed class StreamExtensionsTestFixture + { + [Test] + public void Verify_that_ReadAllBytes_throws_on_null_stream() + { + Stream stream = null; + + Assert.That(() => stream.ReadAllBytes(), + Throws.ArgumentNullException.With.Property(nameof(ArgumentNullException.ParamName)).EqualTo("stream")); + } + + [Test] + public async Task Verify_that_ReadAllBytesAsync_throws_on_null_stream() + { + Stream stream = null; + + var ex = Assert.ThrowsAsync(async () => + await stream.ReadAllBytesAsync(CancellationToken.None)); + + Assert.That(ex!.ParamName, Is.EqualTo("stream")); + } + + [Test] + public void Verify_that_ReadAllBytes_returns_empty_array_for_empty_stream() + { + using var ms = new MemoryStream(Array.Empty()); + + var bytes = ms.ReadAllBytes(); + + Assert.That(bytes, Is.Not.Null); + Assert.That(bytes, Is.Empty); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public async Task Verify_that_ReadAllBytesAsync_returns_empty_array_for_empty_stream() + { + using var ms = new MemoryStream(Array.Empty()); + + var bytes = await ms.ReadAllBytesAsync(); + + Assert.That(bytes, Is.Not.Null); + Assert.That(bytes, Is.Empty); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public void Verify_that_ReadAllBytes_reads_from_current_position_to_end() + { + var payload = CreateBytes(1024); + using var ms = new MemoryStream(payload); + + // advance stream + ms.Position = 100; + + var bytes = ms.ReadAllBytes(); + + Assert.That(bytes, Is.EqualTo(payload.AsSpan(100).ToArray())); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public async Task Verify_that_ReadAllBytesAsync_reads_from_current_position_to_end() + { + var payload = CreateBytes(1024); + await using var ms = new MemoryStream(payload); + + ms.Position = 100; + + var bytes = await ms.ReadAllBytesAsync(); + + Assert.That(bytes, Is.EqualTo(payload.AsSpan(100).ToArray())); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public void Verify_that_ReadAllBytes_handles_buffer_growth() + { + // Larger than initialSize (32 KiB) to force at least one growth. + var payload = CreateBytes(100 * 1024); + using var ms = new MemoryStream(payload); + + var bytes = ms.ReadAllBytes(); + + Assert.That(bytes, Is.EqualTo(payload)); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public async Task Verify_that_ReadAllBytesAsync_handles_buffer_growth() + { + var payload = CreateBytes(100 * 1024); + await using var ms = new MemoryStream(payload); + + var bytes = await ms.ReadAllBytesAsync(); + + Assert.That(bytes, Is.EqualTo(payload)); + Assert.That(ms.Position, Is.EqualTo(ms.Length)); + } + + [Test] + public void Verify_that_ReadAllBytes_does_not_dispose_stream() + { + var payload = CreateBytes(1024); + var ms = new MemoryStream(payload); + + _ = ms.ReadAllBytes(); + + Assert.That(ms.CanRead, Is.True); + Assert.That(ms.CanSeek, Is.True); + + ms.Dispose(); + } + + [Test] + public async Task Verify_that_ReadAllBytesAsync_does_not_dispose_stream() + { + var payload = CreateBytes(1024); + var ms = new MemoryStream(payload); + + _ = await ms.ReadAllBytesAsync(); + + Assert.That(ms.CanRead, Is.True); + Assert.That(ms.CanSeek, Is.True); + + ms.Dispose(); + } + + [Test] + public void Verify_that_ReadAllBytesAsync_honors_cancellation() + { + // Use a stream that produces infinite data to ensure we hit cancellation deterministically. + using var stream = new InfiniteReadStream(); + + using var cts = new CancellationTokenSource(); + cts.Cancel(); + + Assert.That(async () => await stream.ReadAllBytesAsync(cts.Token), + Throws.InstanceOf()); + } + + private static byte[] CreateBytes(int length) + { + var bytes = new byte[length]; + for (var i = 0; i < bytes.Length; i++) + { + bytes[i] = (byte)(i & 0xFF); + } + + return bytes; + } + + /// + /// A stream that always returns data on reads (until canceled). + /// + private sealed class InfiniteReadStream : Stream + { + public override bool CanRead => true; + public override bool CanSeek => false; + public override bool CanWrite => false; + public override long Length => throw new NotSupportedException(); + public override long Position { get => 0; set => throw new NotSupportedException(); } + public override void Flush() => throw new NotSupportedException(); + public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException(); + public override void SetLength(long value) => throw new NotSupportedException(); + public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException(); + + public override int Read(byte[] buffer, int offset, int count) + { + // Produce some bytes without ever reaching end-of-stream. + var n = Math.Min(count, 4096); + Array.Clear(buffer, offset, n); + return n; + } + + public override ValueTask ReadAsync(Memory buffer, CancellationToken cancellationToken = default) + { + cancellationToken.ThrowIfCancellationRequested(); + + var n = Math.Min(buffer.Length, 4096); + buffer.Span.Slice(0, n).Clear(); + return new ValueTask(n); + } + } + } +} diff --git a/SysML2.NET.Extensions.Tests/Utilities/StringExtensionsTestFixture.cs b/SysML2.NET.Extensions.Tests/Utilities/StringExtensionsTestFixture.cs new file mode 100644 index 000000000..5ca810009 --- /dev/null +++ b/SysML2.NET.Extensions.Tests/Utilities/StringExtensionsTestFixture.cs @@ -0,0 +1,120 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Extensions.Utilities.Tests +{ + using System; + + using NUnit.Framework; + + using SysML2.NET.Extensions.Utilities; + + [TestFixture] + public sealed class StringExtensionsTestFixture + { + [Test] + public void Verify_that_NormalizeZipPath_throws_when_path_is_null() + { + string path = null; + + Assert.That(() => path.NormalizeZipPath(), Throws.ArgumentNullException); + } + + [Test] + public void Verify_that_NormalizeZipPath_returns_empty_when_path_is_empty() + { + var result = string.Empty.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo(string.Empty)); + } + + [Test] + public void Verify_that_NormalizeZipPath_replaces_backslashes_with_forward_slashes() + { + var input = @"a\b\c.txt"; + + var result = input.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo("a/b/c.txt")); + } + + [Test] + public void Verify_that_NormalizeZipPath_removes_single_leading_dot_slash() + { + var input = "./Base.kerml"; + + var result = input.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo("Base.kerml")); + } + + [Test] + public void Verify_that_NormalizeZipPath_removes_multiple_leading_dot_slash_segments() + { + var input = "./././folder/Base.kerml"; + + var result = input.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo("folder/Base.kerml")); + } + + [Test] + public void Verify_that_NormalizeZipPath_removes_leading_dot_slash_after_backslash_replacement() + { + var input = @".\.\folder\Base.kerml"; + + var result = input.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo("folder/Base.kerml")); + } + + [Test] + public void Verify_that_NormalizeZipPath_does_not_remove_dot_slash_in_middle_of_path() + { + var input = "folder/./Base.kerml"; + + var result = input.NormalizeZipPath(); + + Assert.That(result, Is.EqualTo("folder/./Base.kerml")); + } + + [Test] + public void Verify_that_NormalizeZipPath_does_not_trim_or_change_other_characters() + { + var input = " ./a/b "; + + var result = input.NormalizeZipPath(); + + // Only "./" prefix is removed; whitespace is preserved by design. + Assert.That(result, Is.EqualTo(" ./a/b ")); + } + + [Test] + public void Verify_that_NormalizeZipPath_is_idempotent() + { + var input = "./a\\b/c.kerml"; + + var once = input.NormalizeZipPath(); + var twice = once.NormalizeZipPath(); + + Assert.That(twice, Is.EqualTo(once)); + } + } +} diff --git a/SysML2.NET.Extensions/ModelInterchange/ArchiveExtensions.cs b/SysML2.NET.Extensions/ModelInterchange/ArchiveExtensions.cs new file mode 100644 index 000000000..e1d2f0e48 --- /dev/null +++ b/SysML2.NET.Extensions/ModelInterchange/ArchiveExtensions.cs @@ -0,0 +1,147 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Extensions.ModelInterchange +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Extensions.Utilities; + + /// + /// Provides convenience methods for working with instances. + /// + public static class ArchiveExtensions + { + /// + /// Tries to resolve a by metadata index key. + /// + /// The to query. + /// + /// The key in that identifies a model file + /// (for example "Base" maps to "Base.kerml"). + /// + /// + /// When this method returns, contains the resolved if found; + /// otherwise, . + /// + /// + /// if an entry is resolved; otherwise, . + /// + public static bool TryGetModelEntryByIndexKey(this Archive archive, string indexKey, out ModelEntry entry) + { + if (archive == null) + { + throw new ArgumentNullException(nameof(archive)); + } + + if (string.IsNullOrWhiteSpace(indexKey)) + { + throw new ArgumentException("The index key shall not be null or empty.", nameof(indexKey)); + } + + entry = null; + + var map = archive.Metadata?.Index; + if (map is null) return false; + + if (!map.TryGetValue(indexKey, out var path) || string.IsNullOrWhiteSpace(path)) + { + return false; + } + + var normalized = path.NormalizeZipPath(); + + entry = archive.Models?.FirstOrDefault(modelEntry => + string.Equals(modelEntry.Path.NormalizeZipPath(), normalized, StringComparison.Ordinal)); + + return entry is not null; + } + + /// + /// Resolves a by metadata index key. + /// + /// The to query. + /// The metadata index key identifying the model file. + /// The resolved . + /// Thrown when is . + /// Thrown when is or empty. + /// + /// Thrown when the archive does not contain metadata index information. + /// + /// + /// Thrown when the metadata index does not contain . + /// + /// + /// Thrown when the metadata index points to a path that is not present in . + /// + public static ModelEntry GetModelEntryByIndexKey(this Archive archive, string indexKey) + { + if (archive is null) throw new ArgumentNullException(nameof(archive)); + + if (string.IsNullOrWhiteSpace(indexKey)) + { + throw new ArgumentException("The index key shall not be null or empty.", nameof(indexKey)); + } + + var map = archive.Metadata?.Index + ?? throw new InvalidOperationException("Archive metadata index is not available."); + + if (!map.TryGetValue(indexKey, out var path) || string.IsNullOrWhiteSpace(path)) + { + throw new KeyNotFoundException($"No index entry found for key '{indexKey}'."); + } + + var normalized = path.NormalizeZipPath(); + + var entry = archive.Models?.FirstOrDefault(m => + string.Equals(m.Path.NormalizeZipPath(), normalized, StringComparison.Ordinal)); + + if (entry is null) + { + throw new FileNotFoundException( + $"Model entry for index key '{indexKey}' points to missing path '{normalized}'.", + normalized); + } + + return entry; + } + + /// + /// Opens the model content stream associated with the specified metadata index key. + /// + /// The to query. + /// The metadata index key identifying the model file. + /// The cancellation token used to cancel the operation. + /// + /// A task that returns a readable stream for the model content. + /// + public static async Task OpenModelByIndexKeyAsync(this Archive archive, string indexKey, CancellationToken cancellationToken = default) + { + var entry = archive.GetModelEntryByIndexKey(indexKey); + return await entry.OpenReadAsync(cancellationToken); + } + } +} diff --git a/SysML2.NET.Extensions/ModelInterchange/ChecksumKindProvider.cs b/SysML2.NET.Extensions/ModelInterchange/ChecksumKindProvider.cs new file mode 100644 index 000000000..376cc8442 --- /dev/null +++ b/SysML2.NET.Extensions/ModelInterchange/ChecksumKindProvider.cs @@ -0,0 +1,264 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System; + using System.Buffers; + + /// + /// Provides high-performance parsing and serialization helpers + /// for the enumeration. + /// + /// + /// + /// This provider enables zero-allocation parsing from + /// , , + /// and values. + /// + /// + /// It is optimized for streaming deserialization scenarios + /// such as System.Text.Json, MessagePack, and custom UTF-8 parsers. + /// + /// + /// All parsing operations use length-based short-circuit evaluation + /// to enable branch prediction and JIT-friendly control flow. + /// + /// + public static class ChecksumKindProvider + { + /// + /// Parses a textual checksum algorithm representation + /// into a enumeration value. + /// + /// + /// The character span representing the checksum algorithm. + /// + /// + /// The corresponding value. + /// + /// + /// Thrown when does not represent + /// a valid checksum algorithm. + /// + /// + /// + /// This overload is suited for string-based parsing. + /// It performs no heap allocations and avoids boxing. + /// + /// + /// Parsing is case-insensitive and uses + /// . + /// + /// + public static ChecksumKind Parse(ReadOnlySpan value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + switch (value.Length) + { + case 3: + if (value.Equals("MD2".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.MD2; + if (value.Equals("MD4".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.MD4; + if (value.Equals("MD5".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.MD5; + if (value.Equals("MD6".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.MD6; + break; + case 4: + if (value.Equals("SHA1".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA1; + break; + case 6: + if (value.Equals("SHA224".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA224; + if (value.Equals("SHA256".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA256; + if (value.Equals("BLAKE3".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.BLAKE3; + break; + case 7: + if (value.Equals("ADLER32".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.ADLER32; + if (value.Equals("SHA-384".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA384; + break; + case 8: + if (value.Equals("SHA3-256".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA3256; + if (value.Equals("SHA3-384".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA3384; + if (value.Equals("SHA3-512".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.SHA3512; + break; + case 11: + if (value.Equals("BLAKE2b-256".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.BLAKE2b256; + if (value.Equals("BLAKE2b-384".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.BLAKE2b384; + if (value.Equals("BLAKE2b-512".AsSpan(), StringComparison.OrdinalIgnoreCase)) return ChecksumKind.BLAKE2b512; + break; + } + + throw new ArgumentException($"'{new string(value)}' is not a valid ChecksumKind", nameof(value)); + } + + /// + /// Parses a UTF-8 encoded byte span into a + /// enumeration value. + /// + /// + /// The UTF-8 encoded byte span representing the checksum algorithm. + /// + /// + /// The corresponding value. + /// + /// + /// Thrown when does not represent + /// a valid checksum algorithm. + /// + public static ChecksumKind Parse(ReadOnlySpan value) + { + if (value == null) + { + throw new ArgumentNullException(nameof(value)); + } + + switch (value.Length) + { + case 3: + if (value.SequenceEqual("MD2"u8)) return ChecksumKind.MD2; + if (value.SequenceEqual("MD4"u8)) return ChecksumKind.MD4; + if (value.SequenceEqual("MD5"u8)) return ChecksumKind.MD5; + if (value.SequenceEqual("MD6"u8)) return ChecksumKind.MD6; + break; + case 4: + if (value.SequenceEqual("SHA1"u8)) return ChecksumKind.SHA1; + break; + case 6: + if (value.SequenceEqual("SHA224"u8)) return ChecksumKind.SHA224; + if (value.SequenceEqual("SHA256"u8)) return ChecksumKind.SHA256; + if (value.SequenceEqual("BLAKE3"u8)) return ChecksumKind.BLAKE3; + break; + case 7: + if (value.SequenceEqual("SHA-384"u8)) return ChecksumKind.SHA384; + if (value.SequenceEqual("ADLER32"u8)) return ChecksumKind.ADLER32; + break; + case 8: + if (value.SequenceEqual("SHA3-256"u8)) return ChecksumKind.SHA3256; + if (value.SequenceEqual("SHA3-384"u8)) return ChecksumKind.SHA3384; + if (value.SequenceEqual("SHA3-512"u8)) return ChecksumKind.SHA3512; + break; + case 11: + if (value.SequenceEqual("BLAKE2b-256"u8)) return ChecksumKind.BLAKE2b256; + if (value.SequenceEqual("BLAKE2b-384"u8)) return ChecksumKind.BLAKE2b384; + if (value.SequenceEqual("BLAKE2b-512"u8)) return ChecksumKind.BLAKE2b512; + break; + } + + throw new ArgumentException($"'{System.Text.Encoding.UTF8.GetString(value)}' is not a valid ChecksumKind", nameof(value)); + } + + /// + /// Parses a UTF-8 encoded into + /// a enumeration value. + /// + /// + /// A UTF-8 encoded sequence representing a checksum algorithm. + /// + /// + /// The corresponding value. + /// + /// + /// Thrown when the supplied sequence does not represent + /// a valid checksum algorithm. + /// + /// + /// + /// If the sequence is contiguous, parsing is delegated to the + /// overload. + /// + /// + /// For multi-segment sequences, the content is copied into + /// a stack-allocated buffer before parsing. + /// + /// + /// No heap allocations are performed. + /// + /// + public static ChecksumKind Parse(in ReadOnlySequence value) + { + if (value.IsSingleSegment) + { + return Parse(value.FirstSpan); + } + + if (value.Length > 16) + { + throw new ArgumentException("Invalid ChecksumKind length", nameof(value)); + } + + Span tmp = stackalloc byte[(int)value.Length]; + value.CopyTo(tmp); + return Parse(tmp); + } + + /// + /// Converts a value into its + /// UTF-8 encoded byte representation. + /// + /// + /// The to convert. + /// + /// + /// A UTF-8 encoded byte span representing the checksum algorithm. + /// + /// + /// Thrown when is not a defined + /// enumeration literal. + /// + /// + /// + /// This method is optimized for serialization scenarios, + /// such as MessagePack or JSON writers. + /// + /// + /// The returned span is backed by static UTF-8 data + /// and remains valid for the lifetime of the process. + /// + /// + /// No heap allocations, no boxing, branch-predictable switch. + /// + /// + public static ReadOnlySpan ToUtf8LowerBytes(ChecksumKind value) + { + return value switch + { + ChecksumKind.SHA1 => "SHA1"u8, + ChecksumKind.SHA224 => "SHA224"u8, + ChecksumKind.SHA256 => "SHA256"u8, + ChecksumKind.SHA384 => "SHA-384"u8, + ChecksumKind.SHA3256 => "SHA3-256"u8, + ChecksumKind.SHA3384 => "SHA3-384"u8, + ChecksumKind.SHA3512 => "SHA3-512"u8, + ChecksumKind.BLAKE2b256 => "BLAKE2b-256"u8, + ChecksumKind.BLAKE2b384 => "BLAKE2b-384"u8, + ChecksumKind.BLAKE2b512 => "BLAKE2b-512"u8, + ChecksumKind.BLAKE3 => "BLAKE3"u8, + ChecksumKind.MD2 => "MD2"u8, + ChecksumKind.MD4 => "MD4"u8, + ChecksumKind.MD5 => "MD5"u8, + ChecksumKind.MD6 => "MD6"u8, + ChecksumKind.ADLER32 => "ADLER32"u8, + _ => throw new ArgumentOutOfRangeException(nameof(value)) + }; + } + } +} diff --git a/SysML2.NET.Extensions/Utilities/StreamExtensions.cs b/SysML2.NET.Extensions/Utilities/StreamExtensions.cs new file mode 100644 index 000000000..896587af0 --- /dev/null +++ b/SysML2.NET.Extensions/Utilities/StreamExtensions.cs @@ -0,0 +1,152 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Extensions.Utilities +{ + using System; + using System.Buffers; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + /// + /// Provides high-performance extension methods for reading + /// instances into byte arrays. + /// + /// + /// These methods: + /// + /// Read from the current stream position to end-of-stream. + /// Use pooled buffers to minimize intermediate allocations. + /// Return a newly allocated, right-sized byte array. + /// + /// The input stream is not disposed. + /// + public static class StreamExtensions + { + /// + /// Reads all remaining bytes from the stream into a new byte array. + /// + /// The readable stream. + /// + /// A byte array whose length exactly matches the number of bytes + /// read from the stream. + /// + /// + /// Thrown when is . + /// + public static byte[] ReadAllBytes(this Stream stream) + { + if (stream is null) throw new ArgumentNullException(nameof(stream)); + + const int initialSize = 32 * 1024; + + var rented = ArrayPool.Shared.Rent(initialSize); + + try + { + var total = 0; + + while (true) + { + var read = stream.Read(rented, total, rented.Length - total); + if (read == 0) break; + + total += read; + + if (total == rented.Length) + { + var newBuf = ArrayPool.Shared.Rent(rented.Length * 2); + Buffer.BlockCopy(rented, 0, newBuf, 0, total); + + ArrayPool.Shared.Return(rented, clearArray: true); + rented = newBuf; + } + } + + var result = new byte[total]; + Buffer.BlockCopy(rented, 0, result, 0, total); + + return result; + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + + /// + /// Asynchronously reads all remaining bytes from the stream into a new byte array. + /// + /// The readable stream. + /// A token used to cancel the operation. + /// + /// A task producing a byte array whose length exactly matches + /// the number of bytes read. + /// + /// + /// Thrown when is . + /// + public static async Task ReadAllBytesAsync(this Stream stream, CancellationToken cancellationToken = default) + { + if (stream is null) throw new ArgumentNullException(nameof(stream)); + + const int initialSize = 32 * 1024; + + var rented = ArrayPool.Shared.Rent(initialSize); + + try + { + var total = 0; + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var read = await stream + .ReadAsync(rented.AsMemory(total, rented.Length - total), cancellationToken) + .ConfigureAwait(false); + + if (read == 0) break; + + total += read; + + if (total == rented.Length) + { + var newBuf = ArrayPool.Shared.Rent(rented.Length * 2); + Buffer.BlockCopy(rented, 0, newBuf, 0, total); + + ArrayPool.Shared.Return(rented, clearArray: true); + rented = newBuf; + } + } + + var result = new byte[total]; + Buffer.BlockCopy(rented, 0, result, 0, total); + + return result; + } + finally + { + ArrayPool.Shared.Return(rented, clearArray: true); + } + } + } +} diff --git a/SysML2.NET.Extensions/Utilities/StringExtensions.cs b/SysML2.NET.Extensions/Utilities/StringExtensions.cs new file mode 100644 index 000000000..ec207636d --- /dev/null +++ b/SysML2.NET.Extensions/Utilities/StringExtensions.cs @@ -0,0 +1,58 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Extensions.Utilities +{ + using System; + + /// + /// The class provides extensions methods for + /// + public static class StringExtensions + { + /// + /// Normalizes a ZIP entry path by converting directory separators to forward + /// slashes ('/') and removing any leading "./" segment. + /// + /// + /// The ZIP entry path to normalize. + /// + /// + /// A normalized path using forward slashes as directory separators and + /// without a leading "./" segment, if present. + /// + public static string NormalizeZipPath(this string path) + { + if (path == null) + { + throw new ArgumentNullException(nameof(path)); + } + + path = path.Replace('\\', '/'); + + while (path.StartsWith("./", StringComparison.Ordinal)) + { + path = path.Substring(2); + } + + return path; + } + } +} diff --git a/SysML2.NET.Kpar.Tests/ArchiveSessionTestFixture.cs b/SysML2.NET.Kpar.Tests/ArchiveSessionTestFixture.cs new file mode 100644 index 000000000..47819947d --- /dev/null +++ b/SysML2.NET.Kpar.Tests/ArchiveSessionTestFixture.cs @@ -0,0 +1,76 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Compression; + + using NUnit.Framework; + + using SysML2.NET.ModelInterchange; + + [TestFixture] + public class ArchiveSessionTestFixture + { + private ArchiveSession archiveSession; + + private static string GetKparPath() + { + return Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "Kernel_Semantic_Library-1.0.0.kpar"); + } + + [SetUp] + public void SetUp() + { + var kparPath = GetKparPath(); + using var fileStream = File.OpenRead(kparPath); + + var zip = new ZipArchive(fileStream, ZipArchiveMode.Read, false); + var archive = new Archive(); + archive.Metadata = new InterchangeProjectMetadata(); + archive.Metadata.Index.Add("123","456"); + + this.archiveSession = new ArchiveSession(fileStream, zip, archive); + } + + [Test] + public void Verify_that_when_on_OpenModel_index_is_null_or_whitespace_exception_is_thrown() + { + Assert.That(() =>this.archiveSession.OpenModel(null), Throws.TypeOf()); + Assert.That(() =>this.archiveSession.OpenModel(""), Throws.TypeOf()); + } + + [Test] + public void Verify_that_when_on_OpenModel_index_is_does_not_exist_exception_is_thrown() + { + Assert.That(() =>this.archiveSession.OpenModel("starion"), Throws.TypeOf()); + } + + [Test] + public void Verify_that_when_on_OpenEntry_path_is_null_or_whitespace_exception_is_thrown() + { + Assert.That(() =>this.archiveSession.OpenEntry(null), Throws.TypeOf()); + Assert.That(() =>this.archiveSession.OpenEntry(""), Throws.TypeOf()); + } + } +} diff --git a/SysML2.NET.Kpar.Tests/Cryptography/Adler32TestFixture.cs b/SysML2.NET.Kpar.Tests/Cryptography/Adler32TestFixture.cs new file mode 100644 index 000000000..43c8607b3 --- /dev/null +++ b/SysML2.NET.Kpar.Tests/Cryptography/Adler32TestFixture.cs @@ -0,0 +1,103 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Tests.Cryptography +{ + using System; + using System.IO; + using System.Text; + using System.Threading; + using System.Threading.Tasks; + + using NUnit.Framework; + + using SysML2.NET.Kpar.Cryptography; + + [TestFixture] + public class Adler32TestFixture + { + [Test] + public void Verify_that_adler32_for_empty_input_is_00000001() + { + var checksum = Adler32.Compute(ReadOnlySpan.Empty); + + Assert.That(checksum, Is.EqualTo(0x00000001u)); + Assert.That(Adler32.ToHexString(checksum), Is.EqualTo("00000001")); + } + + [Test] + public void Verify_that_adler32_for_hello_is_known_value() + { + var data = Encoding.ASCII.GetBytes("Hello"); + + var checksum = Adler32.Compute(data); + + Assert.That(Adler32.ToHexString(checksum), Is.EqualTo("058c01f5")); + } + + [Test] + public void Verify_that_adler32_for_wikipedia_test_vector_is_known_value() + { + var data = Encoding.ASCII.GetBytes("Wikipedia"); + + var checksum = Adler32.Compute(data); + + Assert.That(checksum, Is.EqualTo(0x11E60398u)); + Assert.That(Adler32.ToHexString(checksum), Is.EqualTo("11e60398")); + } + + [Test] + public void Verify_that_stream_and_span_computations_match() + { + var data = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog"); + var spanChecksum = Adler32.Compute(data); + + using var ms = new MemoryStream(data); + var streamChecksum = Adler32.Compute(ms); + + Assert.That(streamChecksum, Is.EqualTo(spanChecksum)); + } + + [Test] + public async Task Verify_that_stream_async_and_span_computations_match() + { + var cts = new CancellationTokenSource(); + + var data = Encoding.UTF8.GetBytes("The quick brown fox jumps over the lazy dog"); + var spanChecksum = Adler32.Compute(data); + + await using var ms = new MemoryStream(data); + var asyncChecksum = await Adler32.ComputeAsync(ms, cts.Token).ConfigureAwait(false); + + Assert.That(asyncChecksum, Is.EqualTo(spanChecksum)); + } + + [Test] + public void Verify_that_adler32_is_deterministic() + { + var data = Encoding.UTF8.GetBytes("Determinism matters."); + + var c1 = Adler32.Compute(data); + var c2 = Adler32.Compute(data); + + Assert.That(c2, Is.EqualTo(c1)); + } + } +} diff --git a/SysML2.NET.Kpar.Tests/Cryptography/ChecksumServiceTestFixture.cs b/SysML2.NET.Kpar.Tests/Cryptography/ChecksumServiceTestFixture.cs new file mode 100644 index 000000000..79e802307 --- /dev/null +++ b/SysML2.NET.Kpar.Tests/Cryptography/ChecksumServiceTestFixture.cs @@ -0,0 +1,258 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Tests.Cryptography +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Threading; + using System.Threading.Tasks; + + using Microsoft.Extensions.Logging; + + using NUnit.Framework; + + using Serilog; + + using SysML2.NET.Kpar.Cryptography; + using SysML2.NET.ModelInterchange; + + [TestFixture] + public class ChecksumServiceTestFixture + { + private ChecksumService checksumService; + + private Reader reader; + + private ILoggerFactory loggerFactory; + + private static string GetKparPath() + { + return Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "Kernel_Semantic_Library-1.0.0.kpar"); + } + + [OneTimeSetUp] + public void OneTimeSetUp() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + + this.loggerFactory = LoggerFactory.Create(builder => { builder.AddSerilog(); }); + } + + [SetUp] + public void SetUp() + { + this.checksumService = new ChecksumService(); + this.reader = new Reader(this.checksumService, this.loggerFactory.CreateLogger()); + this.checksumService = new ChecksumService(); + } + + [Test] + public void Verify_that_checksums_can_be_validated_for_known_kpar() + { + var kparPath = GetKparPath(); + Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}"); + + var archive = this.reader.Read(kparPath); + Assert.That(archive?.Metadata, Is.Not.Null); + + using var zip = ZipFile.OpenRead(kparPath); + + var mismatches = this.checksumService.Validate(zip, archive.Metadata); + + Assert.That(mismatches, Is.Not.Null); + Assert.That(mismatches.Count, Is.EqualTo(0)); + } + + [Test] + public async Task Verify_that_checksums_can_be_validated_async_for_known_kpar() + { + var kparPath = GetKparPath(); + Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}"); + + var archive = await this.reader.ReadAsync(kparPath).ConfigureAwait(false); + Assert.That(archive?.Metadata, Is.Not.Null); + + using var zip = ZipFile.OpenRead(kparPath); + + var mismatches = await this.checksumService.ValidateAsync(zip, archive.Metadata, cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(mismatches, Is.Not.Null); + Assert.That(mismatches.Count, Is.EqualTo(0)); + } + + [Test] + public void Verify_that_checksum_mismatch_throws_by_default() + { + var kparPath = GetKparPath(); + Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}"); + + var archive = this.reader.Read(kparPath); + var tampered = CloneMetadataWithSingleTamperedChecksumValue(archive.Metadata); + + using var zip = ZipFile.OpenRead(kparPath); + + var ex = Assert.Throws(() => this.checksumService.Validate(zip, tampered)); + Assert.That(ex, Is.Not.Null); + Assert.That(ex!.Mismatches, Is.Not.Null); + Assert.That(ex.Mismatches.Count, Is.EqualTo(1)); + + var m = ex.Mismatches[0]; + Assert.That(m.Path, Is.Not.Null.And.Not.Empty); + Assert.That(m.Expected, Is.Not.Null.And.Not.Empty); + Assert.That(m.Actual, Is.Not.Null.And.Not.Empty); + Assert.That(string.Equals(m.Expected, m.Actual, StringComparison.OrdinalIgnoreCase), Is.False); + } + + [Test] + public async Task Verify_that_checksum_mismatch_can_be_returned_when_behavior_is_return_mismatches() + { + var kparPath = GetKparPath(); + Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}"); + + var archive = await this.reader.ReadAsync(kparPath).ConfigureAwait(false); + var tampered = CloneMetadataWithSingleTamperedChecksumValue(archive.Metadata); + + using var zip = ZipFile.OpenRead(kparPath); + + var mismatches = await this.checksumService.ValidateAsync( + zip, + tampered, + behavior: ChecksumFailureBehavior.Collect, + cancellationToken: CancellationToken.None) + .ConfigureAwait(false); + + Assert.That(mismatches, Is.Not.Null); + Assert.That(mismatches.Count, Is.EqualTo(1)); + + var m = mismatches[0]; + Assert.That(m.Path, Is.Not.Null.And.Not.Empty); + Assert.That(m.Expected, Is.Not.Null.And.Not.Empty); + Assert.That(m.Actual, Is.Not.Null.And.Not.Empty); + Assert.That(string.Equals(m.Expected, m.Actual, StringComparison.OrdinalIgnoreCase), Is.False); + } + + [Test] + public void Verify_that_missing_zip_entry_throws_filenotfound() + { + var kparPath = GetKparPath(); + Assert.That(File.Exists(kparPath), Is.True, $"KPAR test file not found: {kparPath}"); + + var archive = this.reader.Read(kparPath); + var tampered = CloneMetadataWithSingleMissingPath(archive.Metadata); + + using var zip = ZipFile.OpenRead(kparPath); + + Assert.Throws(() => this.checksumService.Validate(zip, tampered)); + } + + private static InterchangeProjectMetadata CloneMetadataWithSingleTamperedChecksumValue(InterchangeProjectMetadata original) + { + ArgumentNullException.ThrowIfNull(original); + + if (original.Checksum is null || original.Checksum.Count == 0) + { + throw new InvalidOperationException("Test requires non-empty metadata.Checksum."); + } + + // Shallow clone is sufficient for the test; we only mutate one checksum value. + var clone = new InterchangeProjectMetadata + { + Created = original.Created, + Metamodel = original.Metamodel, + IncludesDerived = original.IncludesDerived, + IncludesImplied = original.IncludesImplied, + Index = original.Index is null ? null : new Dictionary(original.Index, StringComparer.Ordinal), + Checksum = new Dictionary(StringComparer.Ordinal) + }; + + foreach (var kvp in original.Checksum) + { + clone.Checksum[kvp.Key] = new InterchangeChecksum + { + Algorithm = kvp.Value.Algorithm, + Value = kvp.Value.Value + }; + } + + var firstKey = original.Checksum.Keys.First(); + var current = clone.Checksum[firstKey]; + + // Flip a nibble deterministically. + var value = current.Value?.Trim(); + if (string.IsNullOrWhiteSpace(value)) + { + throw new InvalidOperationException("Test requires non-empty checksum value."); + } + + var chars = value.ToCharArray(); + chars[0] = chars[0] == '0' ? '1' : '0'; + current.Value = new string(chars); + + return clone; + } + + private static InterchangeProjectMetadata CloneMetadataWithSingleMissingPath(InterchangeProjectMetadata original) + { + ArgumentNullException.ThrowIfNull(original); + + if (original.Checksum is null || original.Checksum.Count == 0) + { + throw new InvalidOperationException("Test requires non-empty metadata.Checksum."); + } + + var clone = new InterchangeProjectMetadata + { + Created = original.Created, + Metamodel = original.Metamodel, + IncludesDerived = original.IncludesDerived, + IncludesImplied = original.IncludesImplied, + Index = original.Index is null ? null : new Dictionary(original.Index, StringComparer.Ordinal), + Checksum = new Dictionary(StringComparer.Ordinal) + }; + + foreach (var kvp in original.Checksum) + { + clone.Checksum[kvp.Key] = new InterchangeChecksum + { + Algorithm = kvp.Value.Algorithm, + Value = kvp.Value.Value + }; + } + + var firstKey = original.Checksum.Keys.First(); + clone.Checksum.Remove(firstKey); + clone.Checksum["DoesNotExist.kerml"] = new InterchangeChecksum + { + Algorithm = ChecksumKind.SHA256, + Value = "00" + }; + + return clone; + } + } +} diff --git a/SysML2.NET.Kpar.Tests/ReaderTestFixture.cs b/SysML2.NET.Kpar.Tests/ReaderTestFixture.cs new file mode 100644 index 000000000..e740fbd33 --- /dev/null +++ b/SysML2.NET.Kpar.Tests/ReaderTestFixture.cs @@ -0,0 +1,350 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Tests +{ + using System; + using System.Collections.Generic; + using System.IO; + using System.Text; + using System.Threading.Tasks; + + using Microsoft.Extensions.Logging; + + using NUnit.Framework; + + using SysML2.NET.Kpar.Cryptography; + using SysML2.NET.ModelInterchange; + + using Serilog; + + [TestFixture] + public class ReaderTestFixture + { + private Reader reader; + + private ChecksumService checksumService; + + private ILoggerFactory loggerFactory; + + private static string GetKparPath() + { + return Path.Combine(TestContext.CurrentContext.TestDirectory, "TestData", "Kernel_Semantic_Library-1.0.0.kpar"); + } + + [OneTimeSetUp] + public void OneTimeSetUp() + { + Log.Logger = new LoggerConfiguration() + .MinimumLevel.Debug() + .WriteTo.Console() + .CreateLogger(); + + this.loggerFactory = LoggerFactory.Create(builder => { builder.AddSerilog(); }); + } + + [SetUp] + public void SetUp() + { + this.checksumService = new ChecksumService(); + this.reader = new Reader(this.checksumService, this.loggerFactory.CreateLogger()); + } + + private static IEnumerable NullOrEmptyPaths() + { + yield return null; + yield return string.Empty; + } + + [Test] + [TestCaseSource(nameof(NullOrEmptyPaths))] + public void Verify_On_read_or_open_that_when_filepath_is_null_or_empty_argument_exception_is_thrown(string filePath) + { + Assert.That(() => this.reader.Read(filePath), Throws.ArgumentException); + + Assert.That(() => this.reader.Open(filePath), Throws.ArgumentException); + + Assert.That(async () => await this.reader.ReadAsync(filePath), Throws.ArgumentException); + + Assert.That(async () => await this.reader.OpenAsync(filePath), Throws.ArgumentException); + } + + [Test] + public void Verify_that_when_stream_is_null_ArgumentNullException_is_thrown() + { + Stream stream = null; + + Assert.That(() => this.reader.Read(stream), Throws.ArgumentNullException); + + Assert.That( async () => await this.reader.ReadAsync(stream), Throws.ArgumentNullException); + + Assert.That(() => this.reader.Open(stream), Throws.ArgumentNullException); + + Assert.That(async () => await this.reader.OpenAsync(stream), Throws.ArgumentNullException); + } + + [Test] + public void Verify_that_kpar_contents_can_be_read_from_path() + { + var kparPath = GetKparPath(); + + var archive = this.reader.Read(kparPath); + + AssertArchive(archive, expectedPath: kparPath); + } + + [Test] + public void Verify_that_kpar_contents_can_be_read_from_stream() + { + var kparPath = GetKparPath(); + + using var fileStream = File.OpenRead(kparPath); + + var archive = this.reader.Read(fileStream); + + AssertArchive(archive, expectedPath: null); + } + + [Test] + public async Task Verify_that_kpar_contents_can_be_read_async_from_path() + { + var kparPath = GetKparPath(); + + var archive = await this.reader.ReadAsync(kparPath).ConfigureAwait(false); + + AssertArchive(archive, expectedPath: kparPath); + } + + [Test] + public async Task Verify_that_kpar_contents_can_be_read_async_from_stream() + { + var kparPath = GetKparPath(); + + await using var fileStream = File.OpenRead(kparPath); + + var archive = await this.reader.ReadAsync(fileStream).ConfigureAwait(false); + + AssertArchive(archive, expectedPath: null); + } + + [Test] + public void Verify_that_kpar_can_be_opened_from_path_and_model_streams_can_be_opened() + { + var kparPath = GetKparPath(); + + using var archiveSession = this.reader.Open(kparPath); + + AssertArchive(archiveSession.Archive, expectedPath: kparPath); + + using var modelStream = archiveSession.OpenModel("Base"); + Assert.That(modelStream, Is.Not.Null); + Assert.That(modelStream.CanRead, Is.True); + + Assert.That(modelStream.Length, Is.GreaterThan(0)); + + using var reader = new StreamReader(modelStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var content = reader.ReadToEnd(); + + TestContext.WriteLine("---- Base.kerml content ----"); + TestContext.WriteLine(content); + TestContext.WriteLine("---- End of content ----"); + } + + [Test] + public async Task Verify_that_kpar_can_be_opened_async_from_path_and_model_streams_can_be_opened() + { + var kparPath = GetKparPath(); + + await using var archiveSession = await this.reader.OpenAsync(kparPath).ConfigureAwait(false); + + AssertArchive(archiveSession.Archive, expectedPath: kparPath); + + await using var modelStream = archiveSession.OpenModel("Base"); + Assert.That(modelStream, Is.Not.Null); + Assert.That(modelStream.CanRead, Is.True); + Assert.That(modelStream.Length, Is.GreaterThan(0)); + + using var reader = new StreamReader(modelStream, Encoding.UTF8, detectEncodingFromByteOrderMarks: true); + + var content = await reader.ReadToEndAsync(); + + TestContext.WriteLine("---- Base.kerml content ----"); + TestContext.WriteLine(content); + TestContext.WriteLine("---- End of content ----"); + } + + [Test] + public void Verify_that_opened_entry_streams_become_invalid_after_session_dispose() + { + var kparPath = GetKparPath(); + + var archiveSession = this.reader.Open(kparPath); + + var entryStream = archiveSession.OpenModel("Base"); + Assert.That(entryStream.CanRead, Is.True); + + archiveSession.Dispose(); + + Assert.That(() => _ = entryStream.ReadByte(), Throws.Exception, + "Reading from an entry stream after session disposal should fail."); + } + + [Test] + public async Task Verify_that_kpar_can_be_opened_async_from_stream_and_source_is_disposed_on_session_dispose() + { + var kparPath = GetKparPath(); + + var source = File.OpenRead(kparPath); + + ArchiveSession archiveSession = null; + + try + { + archiveSession = await this.reader.OpenAsync(source).ConfigureAwait(false); + + AssertArchive(archiveSession.Archive, expectedPath: null); + + await using var modelStream = archiveSession.OpenModel("Base"); + Assert.That(modelStream.CanRead, Is.True); + Assert.That(modelStream.Length, Is.GreaterThan(0)); + } + finally + { + if (archiveSession is not null) + { + await archiveSession.DisposeAsync().ConfigureAwait(false); + } + } + + Assert.That(source.CanRead, Is.False, "Source stream should be disposed ."); + } + + /// + /// Centralized assertions for all read paths (file/stream, sync/async). + /// + private static void AssertArchive(Archive archive, string expectedPath) + { + Assert.That(archive, Is.Not.Null); + + if (expectedPath is null) + { + Assert.That(archive.Path, Is.Null); + } + else + { + Assert.That(archive.Path, Is.EqualTo(expectedPath)); + } + + // ---- META.JSON ASSERTIONS ---- + Assert.That(archive.Metadata, Is.Not.Null); + + Assert.That(archive.Metadata.Created, Is.EqualTo(DateTimeOffset.Parse("2025-03-13T00:00:00Z"))); + Assert.That(archive.Metadata.Metamodel, Is.EqualTo("https://www.omg.org/spec/KerML/20250201")); + + Assert.That(archive.Metadata.IncludesDerived, Is.False); + Assert.That(archive.Metadata.IncludesImplied, Is.False); + + Assert.That(archive.Metadata.Index, Is.Not.Null); + Assert.That(archive.Metadata.Index.Count, Is.EqualTo(16)); + + Assert.Multiple(() => + { + Assert.That(archive.Metadata.Index["Base"], Is.EqualTo("Base.kerml")); + Assert.That(archive.Metadata.Index["Clocks"], Is.EqualTo("Clocks.kerml")); + Assert.That(archive.Metadata.Index["ControlPerformances"], Is.EqualTo("ControlPerformances.kerml")); + Assert.That(archive.Metadata.Index["FeatureReferencingPerformances"], Is.EqualTo("FeatureReferencingPerformances.kerml")); + Assert.That(archive.Metadata.Index["KerML"], Is.EqualTo("KerML.kerml")); + Assert.That(archive.Metadata.Index["Links"], Is.EqualTo("Links.kerml")); + Assert.That(archive.Metadata.Index["Metaobjects"], Is.EqualTo("Metaobjects.kerml")); + Assert.That(archive.Metadata.Index["Objects"], Is.EqualTo("Objects.kerml")); + Assert.That(archive.Metadata.Index["Observation"], Is.EqualTo("Observation.kerml")); + Assert.That(archive.Metadata.Index["Occurrences"], Is.EqualTo("Occurrences.kerml")); + Assert.That(archive.Metadata.Index["Performances"], Is.EqualTo("Performances.kerml")); + Assert.That(archive.Metadata.Index["SpatialFrames"], Is.EqualTo("SpatialFrames.kerml")); + Assert.That(archive.Metadata.Index["StatePerformances"], Is.EqualTo("StatePerformances.kerml")); + Assert.That(archive.Metadata.Index["Transfers"], Is.EqualTo("Transfers.kerml")); + Assert.That(archive.Metadata.Index["TransitionPerformances"], Is.EqualTo("TransitionPerformances.kerml")); + Assert.That(archive.Metadata.Index["Triggers"], Is.EqualTo("Triggers.kerml")); + }); + + Assert.That(archive.Metadata.Checksum, Is.Not.Null); + Assert.That(archive.Metadata.Checksum.Count, Is.EqualTo(16)); + + void AssertChecksum(string path, string expectedValue) + { + Assert.That(archive.Metadata.Checksum.ContainsKey(path), Is.True, $"Missing checksum for '{path}'"); + + var entry = archive.Metadata.Checksum[path]; + + Assert.That(entry, Is.Not.Null); + Assert.That(entry.Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + Assert.That(entry.Value, Is.EqualTo(expectedValue)); + } + + Assert.Multiple(() => + { + AssertChecksum("Triggers.kerml", "124cad3625935e078d1363e6100ee12537ca9c51445a18108e056db8b4885609"); + AssertChecksum("ControlPerformances.kerml", "31385be7dca94bd0538f011d5c8f7925626d54f96970769f0fdb28b2186a9a03"); + AssertChecksum("Transfers.kerml", "fa40b483a7834d89f07aad0f6f57e79244adc2a58b4396c4734bddeb297d7c46"); + AssertChecksum("Objects.kerml", "9057e2781fe8793d5108973c0647318caa26310be6231c6380152a4cbc894c25"); + AssertChecksum("Metaobjects.kerml", "983dbd85a4b183d8859326ee512fc59d991fb98a115e009e72fad21d1f9d1685"); + AssertChecksum("Performances.kerml", "fd965e184b300737a192530de0c800cdbee236cb6220612f370400da21dfb327"); + AssertChecksum("StatePerformances.kerml", "f02fb7e8de58f4304c95c575ee1bcb7d271d621ce8e336ce36ea80a4e956c3da"); + AssertChecksum("Base.kerml", "56df84cda67f62c63d4e79e2786fc26046cfa361a958c4fcf0843d32a5707e09"); + AssertChecksum("Observation.kerml", "6bc57a73c43af6f61201b6eb659024a9f08f974643eb5a101e068e3637761ee4"); + AssertChecksum("TransitionPerformances.kerml", "1ce78437c817c8359a2cad43e8e72b23dd32b81d2a69dc1126c803fae72aae70"); + AssertChecksum("FeatureReferencingPerformances.kerml", "b6f9e5349c7c7f393591c0334c3bec86f1766b3e37209819179310c2f8fe1fb7"); + AssertChecksum("KerML.kerml", "8fdf4b7416e981c895cd74b75dc14b18091d13cbcaff7cdded6f9c23e2483d58"); + AssertChecksum("Occurrences.kerml", "b3a62ce0bc3a4f7e667102b4c2f68a4928ca8efeda425c6a4c8bdeadfbc9bbc1"); + AssertChecksum("Clocks.kerml", "960ac0884935e308beea55c78ed11b6946c37a386eb7958ef2c913aa275ae4c7"); + AssertChecksum("Links.kerml", "dcf3c002717cb91f8e16f1890fdf5526f4e178ada898a189621c7d0c24b5ddc0"); + AssertChecksum("SpatialFrames.kerml", "2a7790ebc2afacbd64eb781567906921e38eff385c917be03b090b8289353de7"); + }); + + foreach (var (_, path) in archive.Metadata.Index) + { + Assert.That( + archive.Metadata.Checksum.ContainsKey(path), + Is.True, + $"Index path '{path}' should have a checksum entry"); + } + + // ---- PROJECT.JSON ASSERTIONS ---- + Assert.That(archive.Project, Is.Not.Null); + + Assert.That(archive.Project.Name, Is.EqualTo("Kernel Semantic Library")); + Assert.That(archive.Project.Description, Is.EqualTo("Standard semantic library for the Kernel Modeling Language (KerML)")); + Assert.That(archive.Project.Version, Is.EqualTo("1.0.0")); + + Assert.That(archive.Project.Usage, Is.Not.Null); + Assert.That(archive.Project.Usage.Count, Is.EqualTo(2)); + + Assert.Multiple(() => + { + Assert.That(archive.Project.Usage[0].Resource, Is.EqualTo(new Uri("https://www.omg.org/spec/KerML/20250201/Data-Type-Library.kpar"))); + Assert.That(archive.Project.Usage[0].VersionConstraint, Is.EqualTo("1.0.0")); + + Assert.That(archive.Project.Usage[1].Resource, Is.EqualTo(new Uri("https://www.omg.org/spec/KerML/20250201/Function-Library.kpar"))); + Assert.That(archive.Project.Usage[1].VersionConstraint, Is.EqualTo("1.0.0")); + }); + } + } +} diff --git a/SysML2.NET.Kpar.Tests/SysML2.NET.Kpar.Tests.csproj b/SysML2.NET.Kpar.Tests/SysML2.NET.Kpar.Tests.csproj new file mode 100644 index 000000000..c4afd5cf5 --- /dev/null +++ b/SysML2.NET.Kpar.Tests/SysML2.NET.Kpar.Tests.csproj @@ -0,0 +1,77 @@ + + + + net9.0 + 12.0 + Starion Group S.A. + Sam Gerene + Nunit test suite for the SysML2.NET.Kpar library + Copyright © Starion Group S.A. + Apache-2.0 + https://github.com/STARIONGROUP/SysML2.NET.git + Git + false + disable + false + true + en-US + + + + + + + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + Always + + + + + + + + + + + + runtime; build; native; contentfiles; analyzers; buildtransitive + all + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + diff --git a/SysML2.NET.Kpar.Tests/WriterTestFixture.cs b/SysML2.NET.Kpar.Tests/WriterTestFixture.cs new file mode 100644 index 000000000..902646720 --- /dev/null +++ b/SysML2.NET.Kpar.Tests/WriterTestFixture.cs @@ -0,0 +1,34 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2025 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Tests +{ + using NUnit.Framework; + + [TestFixture] + public class WriterTestFixture + { + [Test] + public void Inconclusive() + { + Assert.Inconclusive("write some tests when writer is implemented"); + } + } +} diff --git a/SysML2.NET.Kpar/ArchiveSession.cs b/SysML2.NET.Kpar/ArchiveSession.cs new file mode 100644 index 000000000..ca7e66594 --- /dev/null +++ b/SysML2.NET.Kpar/ArchiveSession.cs @@ -0,0 +1,144 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System; + using System.IO; + using System.IO.Compression; + using System.Threading.Tasks; + using Extensions.ModelInterchange; + using SysML2.NET.Extensions.Utilities; + using SysML2.NET.ModelInterchange; + + /// + /// Represents an open session on a .kpar archive that keeps the underlying ZIP container + /// and source stream alive so that model content streams can be opened on demand. + /// + /// + /// The caller must dispose the session to release the underlying and + /// its backing . Any streams opened from the session become invalid once the + /// session is disposed. + /// + public sealed class ArchiveSession : IDisposable, IAsyncDisposable + { + /// + /// The underlying source stream that contains the .kpar ZIP archive. + /// + /// + /// This stream is kept alive for the lifetime of the + /// to allow on-demand access to ZIP entries. It is disposed when the session + /// is disposed, unless ownership is externally controlled. + /// + private readonly Stream source; + + /// + /// The open representing the .kpar container. + /// + /// + /// The ZIP archive remains open for the lifetime of the session to enable + /// deferred opening of model entry streams. Disposing the session disposes + /// this archive instance. + /// + private readonly ZipArchive zip; + + /// + /// Initializes a new instance of the class. + /// + /// The backing stream that contains the .kpar ZIP payload. + /// The open used to access entries. + /// The parsed logical archive representation. + internal ArchiveSession(Stream source, ZipArchive zip, Archive archive) + { + this.source = source ?? throw new ArgumentNullException(nameof(source)); + this.zip = zip ?? throw new ArgumentNullException(nameof(zip)); + this.Archive = archive ?? throw new ArgumentNullException(nameof(archive)); + } + + /// + /// Gets the logical archive contents parsed from the .kpar descriptors. + /// + public Archive Archive { get; } + + /// + /// Opens a model entry stream by metadata index key (for example "Base"). + /// + /// The key in .meta.json index. + /// A readable stream for the model file mapped by . + /// Thrown when is null or empty. + /// Thrown when metadata index is not available. + /// Thrown when is not present. + /// Thrown when the mapped path does not exist in the archive. + public Stream OpenModel(string indexKey) + { + var modelEntry = this.Archive.GetModelEntryByIndexKey(indexKey); + + return this.OpenEntry(modelEntry.Path); + } + + /// + /// Opens an entry stream by archive-relative path. + /// + /// + /// The archive-relative path of the entry to open (forward slashes preferred). + /// + /// + /// A readable stream for the requested entry. + /// + /// + /// Thrown when is null or empty. + /// + /// + /// Thrown when the specified entry does not exist in the archive. + /// + internal Stream OpenEntry(string archivePath) + { + if (string.IsNullOrWhiteSpace(archivePath)) + { + throw new ArgumentException("Archive path shall not be null or empty.", nameof(archivePath)); + } + + var normalized = archivePath.NormalizeZipPath(); + + var entry = this.zip.GetEntry(normalized); + + if (entry is null) + { + throw new FileNotFoundException($"kpar entry '{normalized}' not found.", normalized); + } + + return entry.Open(); + } + + /// + public void Dispose() + { + this.zip.Dispose(); + this.source.Dispose(); + } + + /// + public ValueTask DisposeAsync() + { + this.zip.Dispose(); + return this.source.DisposeAsync(); + } + } +} diff --git a/SysML2.NET.Kpar/ChecksumFailureBehavior.cs b/SysML2.NET.Kpar/ChecksumFailureBehavior.cs new file mode 100644 index 000000000..88c0c3769 --- /dev/null +++ b/SysML2.NET.Kpar/ChecksumFailureBehavior.cs @@ -0,0 +1,38 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + /// + /// Defines how checksum mismatches are handled when checksum validation is enabled. + /// + public enum ChecksumFailureBehavior + { + /// + /// Throws a if one or more mismatches are detected. + /// + Throw = 0, + + /// + /// Collects mismatches and exposes them to the caller without throwing. + /// + Collect = 1 + } +} diff --git a/SysML2.NET.Kpar/ChecksumValidationException.cs b/SysML2.NET.Kpar/ChecksumValidationException.cs new file mode 100644 index 000000000..3b3db1617 --- /dev/null +++ b/SysML2.NET.Kpar/ChecksumValidationException.cs @@ -0,0 +1,54 @@ +namespace SysML2.NET.Kpar +{ + using System; + using System.Collections.Generic; + + using SysML2.NET.ModelInterchange; + + /// + /// Represents an exception that is thrown when checksum validation of a + /// .kpar archive fails. + /// + /// + /// This exception is raised when checksum validation is enabled via + /// and one or more model + /// files contained in the archive do not match the checksum values + /// declared in .meta.json. + /// + /// The default behavior (when is configured + /// to throw) is to abort archive opening and throw this exception + /// immediately after validation. + /// + /// The exception exposes detailed mismatch information through + /// the property. + /// + public sealed class ChecksumValidationException : InvalidOperationException + { + /// + /// Initializes a new instance of the class. + /// + /// + /// The collection of checksum mismatches detected during validation. + /// + /// + /// The exception message summarizes the number of failed archive entries. + /// Detailed information about each mismatch is available via + /// the property. + /// + public ChecksumValidationException(IReadOnlyList mismatches) + : base($"Checksum validation failed for {mismatches?.Count ?? 0} archive entr{(mismatches?.Count == 1 ? "y" : "ies")}.") + { + this.Mismatches = mismatches ?? Array.Empty(); + } + + /// + /// Gets the collection of checksum mismatches detected during archive validation. + /// + /// + /// A read-only list of instances describing + /// each model entry whose computed checksum differed from the value declared + /// in .meta.json. + /// + public IReadOnlyList Mismatches { get; } + } +} diff --git a/SysML2.NET.Kpar/Cryptography/Adler32.cs b/SysML2.NET.Kpar/Cryptography/Adler32.cs new file mode 100644 index 000000000..61d4c4e27 --- /dev/null +++ b/SysML2.NET.Kpar/Cryptography/Adler32.cs @@ -0,0 +1,127 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Cryptography +{ + using System; + using System.IO; + using System.Threading; + + /// + /// Provides an implementation of the Adler-32 checksum algorithm as defined in RFC 1950. + /// + /// + /// Adler-32 is a non-cryptographic checksum algorithm primarily used in zlib. + /// It is fast but not collision-resistant and MUST NOT be used for security-sensitive integrity validation. + /// + public static class Adler32 + { + private const uint ModAdler = 65521; + + /// + /// Computes the Adler-32 checksum for the specified byte span. + /// + /// The input data. + /// The computed Adler-32 checksum. + public static uint Compute(ReadOnlySpan data) + { + uint a = 1; + uint b = 0; + + foreach (var t in data) + { + a = (a + t) % ModAdler; + b = (b + a) % ModAdler; + } + + return (b << 16) | a; + } + + /// + /// Computes the Adler-32 checksum for the specified stream. + /// + /// The input stream. Must be readable. + /// The computed Adler-32 checksum. + /// Thrown if is null. + public static uint Compute(Stream stream) + { + if (stream == null) + throw new ArgumentNullException(nameof(stream)); + + uint a = 1; + uint b = 0; + + Span buffer = stackalloc byte[8192]; + + int read; + while ((read = stream.Read(buffer)) > 0) + { + for (int i = 0; i < read; i++) + { + a = (a + buffer[i]) % ModAdler; + b = (b + a) % ModAdler; + } + } + + return (b << 16) | a; + } + + /// + /// Computes the Adler-32 checksum for the specified stream asynchronously. + /// + /// The input stream. Must be readable. + /// A token used to cancel the operation. + /// The computed Adler-32 checksum. + /// Thrown if is null. + public static async System.Threading.Tasks.Task ComputeAsync(Stream stream, CancellationToken cancellationToken ) + { + if (stream == null) throw new ArgumentNullException(nameof(stream)); + + uint a = 1; + uint b = 0; + + byte[] buffer = new byte[8192]; + + int read; + while ((read = await stream.ReadAsync(buffer).ConfigureAwait(false)) > 0) + { + cancellationToken.ThrowIfCancellationRequested(); + + for (int i = 0; i < read; i++) + { + a = (a + buffer[i]) % ModAdler; + b = (b + a) % ModAdler; + } + } + + return (b << 16) | a; + } + + /// + /// Converts the checksum value to a lowercase hexadecimal string (8 characters). + /// + /// The checksum value. + /// Lowercase hexadecimal string representation. + public static string ToHexString(uint checksum) + { + return checksum.ToString("x8"); + } + } +} diff --git a/SysML2.NET.Kpar/Cryptography/ChecksumService.cs b/SysML2.NET.Kpar/Cryptography/ChecksumService.cs new file mode 100644 index 000000000..162003d81 --- /dev/null +++ b/SysML2.NET.Kpar/Cryptography/ChecksumService.cs @@ -0,0 +1,408 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Cryptography +{ + using System; + using System.Buffers; + using System.Collections.Generic; + using System.Globalization; + using System.IO; + using System.IO.Compression; + using System.Threading; + using System.Threading.Tasks; + + using Org.BouncyCastle.Crypto; + using Org.BouncyCastle.Crypto.Digests; + + using SysML2.NET.Extensions.Utilities; + using SysML2.NET.Kpar; + using SysML2.NET.ModelInterchange; + + /// + /// Provides checksum computation and validation utilities for .kpar archives. + /// + /// + /// This service: + /// + /// Computes checksums for supported values. + /// Validates the map against the actual ZIP entry contents. + /// Uses BouncyCastle for most digest algorithms and a built-in implementation for . + /// + /// + /// Algorithms that are not available in the current dependency set will raise . + /// + /// + public sealed class ChecksumService : IChecksumService + { + /// + /// Validates all checksum entries in against the corresponding ZIP entries. + /// + /// The open ZIP archive containing the .kpar entries. + /// The parsed .meta.json metadata. + /// + /// Controls what happens when mismatches are detected. The default is . + /// + /// + /// A read-only list of detected mismatches. If is , + /// this method throws instead of returning a non-empty list. + /// + public IReadOnlyList Validate(ZipArchive zip, InterchangeProjectMetadata metadata, ChecksumFailureBehavior behavior = ChecksumFailureBehavior.Throw) + { + if (zip is null) throw new ArgumentNullException(nameof(zip)); + + if (metadata is null) throw new ArgumentNullException(nameof(metadata)); + + var checksums = metadata.Checksum; + if (checksums is null || checksums.Count == 0) + { + return Array.Empty(); + } + + var mismatches = new List(); + + foreach (var kvp in checksums) + { + var archivePath = kvp.Key?.NormalizeZipPath(); + var expected = kvp.Value; + + if (string.IsNullOrWhiteSpace(archivePath) || expected is null) + { + continue; + } + + var entry = zip.GetEntry(archivePath); + if (entry is null) + { + throw new FileNotFoundException($"ZIP entry '{archivePath}' not found for checksum validation.", + archivePath); + } + + using var stream = entry.Open(); + var actualHex = ComputeHex(stream, expected.Algorithm); + + if (!HexEquals(actualHex, expected.Value)) + { + mismatches.Add(new ChecksumMismatch + { + IndexKey = kvp.Key, + Path = archivePath, + Algorithm = expected.Algorithm, + Expected =expected.Value, + Actual =actualHex + }); + } + } + + if (mismatches.Count > 0 && behavior == ChecksumFailureBehavior.Throw) + { + throw new ChecksumValidationException(mismatches); + } + + return mismatches; + } + + /// + /// Asynchronously validates all checksum entries in against the corresponding ZIP entries. + /// + /// The open ZIP archive containing the .kpar entries. + /// The parsed .meta.json metadata. + /// + /// Controls what happens when mismatches are detected. The default is . + /// + /// A token used to cancel the operation. + /// + /// A task that returns a read-only list of detected mismatches. If is , + /// this method throws instead of returning a non-empty list. + /// + public async Task> ValidateAsync(ZipArchive zip, InterchangeProjectMetadata metadata, ChecksumFailureBehavior behavior = ChecksumFailureBehavior.Throw, CancellationToken cancellationToken = default) + { + if (zip is null) throw new ArgumentNullException(nameof(zip)); + + if (metadata is null) throw new ArgumentNullException(nameof(metadata)); + + var checksums = metadata.Checksum; + if (checksums is null || checksums.Count == 0) + { + return Array.Empty(); + } + + var mismatches = new List(); + + foreach (var kvp in checksums) + { + cancellationToken.ThrowIfCancellationRequested(); + + var archivePath = kvp.Key?.NormalizeZipPath(); + var expected = kvp.Value; + + if (string.IsNullOrWhiteSpace(archivePath) || expected is null) + { + continue; + } + + var entry = zip.GetEntry(archivePath); + if (entry is null) + { + throw new FileNotFoundException($"ZIP entry '{archivePath}' not found for checksum validation.", + archivePath); + } + + await using var stream = entry.Open(); + var actualHex = await ComputeHexAsync(stream, expected.Algorithm, cancellationToken) + .ConfigureAwait(false); + + if (!HexEquals(actualHex, expected.Value)) + { + mismatches.Add(new ChecksumMismatch + { + IndexKey = kvp.Key, + Path = archivePath, + Algorithm = expected.Algorithm, + Expected =expected.Value, + Actual =actualHex + }); + } + } + + if (mismatches.Count > 0 && behavior == ChecksumFailureBehavior.Throw) + { + throw new ChecksumValidationException(mismatches); + } + + return mismatches; + } + + /// + /// Computes the checksum over the provided and returns a lowercase hex string. + /// + /// The input stream to hash. + /// The checksum algorithm to apply. + /// A lowercase hex string representing the computed checksum. + private static string ComputeHex(Stream stream, ChecksumKind kind) + { + if (stream is null) throw new ArgumentNullException(nameof(stream)); + + if (kind == ChecksumKind.ADLER32) + { + var adler = Adler32.Compute(stream); + return adler.ToString("x8", CultureInfo.InvariantCulture); + } + + var digest = CreateDigest(kind); + ComputeDigest(stream, digest); + var output = new byte[digest.GetDigestSize()]; + digest.DoFinal(output, 0); + return ToLowerHex(output); + } + + /// + /// Asynchronously computes the checksum over the provided and returns a lowercase hex string. + /// + /// The input stream to hash. + /// The checksum algorithm to apply. + /// A token used to cancel the operation. + /// A task returning a lowercase hex string representing the computed checksum. + private static async Task ComputeHexAsync(Stream stream, ChecksumKind kind, CancellationToken cancellationToken = default) + { + if (stream is null) throw new ArgumentNullException(nameof(stream)); + + if (kind == ChecksumKind.ADLER32) + { + var adler = await Adler32.ComputeAsync(stream, cancellationToken).ConfigureAwait(false); + return adler.ToString("x8", CultureInfo.InvariantCulture); + } + + var digest = CreateDigest(kind); + await ComputeDigestAsync(stream, digest, cancellationToken).ConfigureAwait(false); + var output = new byte[digest.GetDigestSize()]; + digest.DoFinal(output, 0); + return ToLowerHex(output); + } + + /// + /// Creates a BouncyCastle instance for the specified . + /// + /// The checksum algorithm to instantiate. + /// + /// A concrete implementation that can be fed with input bytes via + /// . + /// + /// + /// + /// This factory covers digest algorithms that are available in the referenced BouncyCastle package, + /// and intentionally throws for algorithms that require an additional dependency (for example BLAKE3, MD6). + /// + /// + /// Note that is not a cryptographic digest and is typically handled + /// outside BouncyCastle (for example with a dedicated Adler-32 implementation). + /// + /// + private static IDigest CreateDigest(ChecksumKind kind) + { + return kind switch + { + ChecksumKind.SHA1 => new Sha1Digest(), + ChecksumKind.SHA224 => new Sha224Digest(), + ChecksumKind.SHA256 => new Sha256Digest(), + ChecksumKind.SHA384 => new Sha384Digest(), + + ChecksumKind.SHA3256 => new Sha3Digest(256), + ChecksumKind.SHA3384 => new Sha3Digest(384), + ChecksumKind.SHA3512 => new Sha3Digest(512), + + // Blake2bDigest takes output size in bits. + ChecksumKind.BLAKE2b256 => new Blake2bDigest(256), + ChecksumKind.BLAKE2b384 => new Blake2bDigest(384), + ChecksumKind.BLAKE2b512 => new Blake2bDigest(512), + + ChecksumKind.MD2 => new MD2Digest(), + ChecksumKind.MD4 => new MD4Digest(), + ChecksumKind.MD5 => new MD5Digest(), + + // Not available in BouncyCastle by default (and not provided here). + ChecksumKind.BLAKE3 => throw new NotSupportedException( + "BLAKE3 is not supported by the current implementation. Add a BLAKE3 implementation and extend CreateDigest/ComputeHex accordingly."), + ChecksumKind.MD6 => throw new NotSupportedException( + "MD6 is not supported by the current implementation. Add an MD6 implementation and extend CreateDigest/ComputeHex accordingly."), + + _ => throw new NotSupportedException($"Checksum algorithm '{kind}' is not supported.") + }; + } + + /// + /// Reads all bytes from and feeds them into the provided . + /// + /// The readable input stream to hash from its current position to end-of-stream. + /// The digest instance that will receive the input bytes. + /// + /// Thrown when or is . + /// + /// + /// + /// This method does not reset the stream position. After completion, the stream is positioned at end-of-stream. + /// + /// + /// A pooled buffer is used to reduce allocations. The buffer is returned to the pool even if an exception occurs. + /// + /// + private static void ComputeDigest(Stream stream, IDigest digest) + { + var buffer = ArrayPool.Shared.Rent(64 * 1024); + try + { + int read; + while ((read = stream.Read(buffer, 0, buffer.Length)) > 0) + { + digest.BlockUpdate(buffer, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Asynchronously reads all bytes from and feeds them into the provided . + /// + /// The readable input stream to hash from its current position to end-of-stream. + /// The digest instance that will receive the input bytes. + /// A token used to cancel the operation. + /// A task that completes when all bytes have been read and fed to the digest. + /// + /// + /// This method does not reset the stream position. After completion, the stream is positioned at end-of-stream. + /// + /// + /// A pooled buffer is used to reduce allocations. The buffer is returned to the pool even if an exception occurs. + /// + /// + private static async Task ComputeDigestAsync(Stream stream, IDigest digest, CancellationToken cancellationToken) + { + var buffer = ArrayPool.Shared.Rent(64 * 1024); + try + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var read = await stream.ReadAsync(buffer.AsMemory(0, buffer.Length), cancellationToken).ConfigureAwait(false); + if (read <= 0) break; + + digest.BlockUpdate(buffer, 0, read); + } + } + finally + { + ArrayPool.Shared.Return(buffer); + } + } + + /// + /// Compares two hexadecimal strings for equality, ignoring case and surrounding whitespace. + /// + /// The first hexadecimal string. + /// The second hexadecimal string. + /// + /// if both strings are non-null and represent the same sequence of hex characters + /// (case-insensitive, trimmed); otherwise . + /// + /// + /// This helper is intentionally permissive to tolerate differences in casing and incidental whitespace + /// in descriptor files. + /// + private static bool HexEquals(string a, string b) + { + if (a is null || b is null) return false; + + return string.Equals(a.Trim(), b.Trim(), StringComparison.OrdinalIgnoreCase); + } + + /// + /// Converts the provided bytes to a lowercase hexadecimal string without separators. + /// + /// The bytes to encode as hexadecimal. + /// + /// A lowercase hexadecimal string whose length is bytes.Length * 2. + /// + /// + /// This method performs no allocations besides the resulting string and does not use culture-sensitive formatting. + /// + private static string ToLowerHex(ReadOnlySpan bytes) + { + // 2 chars per byte. + var chars = new char[bytes.Length * 2]; + var c = 0; + + for (var i = 0; i < bytes.Length; i++) + { + var b = bytes[i]; + chars[c++] = GetLowerHexNibble(b >> 4); + chars[c++] = GetLowerHexNibble(b & 0xF); + } + + return new string(chars); + + static char GetLowerHexNibble(int value) + => (char)(value < 10 ? ('0' + value) : ('a' + (value - 10))); + } + } +} diff --git a/SysML2.NET.Kpar/Cryptography/IChecksumService.cs b/SysML2.NET.Kpar/Cryptography/IChecksumService.cs new file mode 100644 index 000000000..da4547194 --- /dev/null +++ b/SysML2.NET.Kpar/Cryptography/IChecksumService.cs @@ -0,0 +1,75 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar.Cryptography +{ + using System; + using System.Collections.Generic; + using System.IO.Compression; + using System.Threading; + using System.Threading.Tasks; + using SysML2.NET.ModelInterchange; + + /// + /// Provides checksum computation and validation utilities for .kpar archives. + /// + /// + /// This service: + /// + /// Computes checksums for supported values. + /// Validates the map against the actual ZIP entry contents. + /// Uses BouncyCastle for most digest algorithms and a built-in implementation for . + /// + /// + /// Algorithms that are not available in the current dependency set will raise . + /// + /// + public interface IChecksumService + { + /// + /// Validates all checksum entries in against the corresponding ZIP entries. + /// + /// The open ZIP archive containing the .kpar entries. + /// The parsed .meta.json metadata. + /// + /// Controls what happens when mismatches are detected. The default is . + /// + /// + /// A read-only list of detected mismatches. If is , + /// this method throws instead of returning a non-empty list. + /// + IReadOnlyList Validate(ZipArchive zip, InterchangeProjectMetadata metadata, ChecksumFailureBehavior behavior = ChecksumFailureBehavior.Throw); + + /// + /// Asynchronously validates all checksum entries in against the corresponding ZIP entries. + /// + /// The open ZIP archive containing the .kpar entries. + /// The parsed .meta.json metadata. + /// + /// Controls what happens when mismatches are detected. The default is . + /// + /// A token used to cancel the operation. + /// + /// A task that returns a read-only list of detected mismatches. If is , + /// this method throws instead of returning a non-empty list. + /// + Task> ValidateAsync(ZipArchive zip, InterchangeProjectMetadata metadata, ChecksumFailureBehavior behavior = ChecksumFailureBehavior.Throw, CancellationToken cancellationToken = default); + } +} diff --git a/SysML2.NET.Kpar/DictionaryExtensions.cs b/SysML2.NET.Kpar/DictionaryExtensions.cs new file mode 100644 index 000000000..5c52acd54 --- /dev/null +++ b/SysML2.NET.Kpar/DictionaryExtensions.cs @@ -0,0 +1,52 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System; + using System.Collections.Generic; + using System.IO.Compression; + using System.Linq; + + /// + /// Extension methods for used by the .kpar ZIP reader. + /// + internal static class DictionaryExtensions + { + /// + /// Removes all entries whose key ends with the specified suffix, using an ordinal case-insensitive comparison. + /// + /// + /// The dictionary from which entries will be removed. + /// + /// + /// The suffix to match against dictionary keys (for example .project.json or .meta.json). + /// + /// + /// This method enumerates the dictionary keys to compute the removal set first, then removes matching entries. + /// This avoids modifying the dictionary while iterating it. + /// + internal static void RemoveWhereKeyEndsWith(this Dictionary dictionary, string suffix) + { + var keys = dictionary.Keys.Where(k => k.EndsWith(suffix, StringComparison.OrdinalIgnoreCase)).ToList(); + foreach (var k in keys) dictionary.Remove(k); + } + } +} diff --git a/SysML2.NET.Kpar/IReader.cs b/SysML2.NET.Kpar/IReader.cs new file mode 100644 index 000000000..704fe70a2 --- /dev/null +++ b/SysML2.NET.Kpar/IReader.cs @@ -0,0 +1,130 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + using SysML2.NET.ModelInterchange; + + /// + /// Reads KerML Project Archives (.kpar) from a file path or stream. + /// + /// + /// A reader is expected to: + /// + /// Open the ZIP container. + /// Locate and parse .project.json and .meta.json at archive root. + /// Expose model interchange files (which may reside in subfolders) by their archive-relative paths. + /// + /// + public interface IReader + { + /// + /// Reads a .kpar file from a file path. + /// + /// Absolute or relative path to the archive. + /// Optional read options. + /// A parsed . + Archive Read(string filePath, ReadOptions options = null); + + /// + /// Reads a .kpar from an input stream. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// A parsed . + Archive Read(Stream source, ReadOptions options = null); + + /// + /// Reads a .kpar file from a file path asynchronously. + /// + /// Absolute or relative path to the archive. + /// Optional read options. + /// Cancellation token. + /// A parsed . + Task ReadAsync(string filePath, ReadOptions options = null, CancellationToken cancellationToken = default); + + /// + /// Reads a .kpar from an input stream asynchronously. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// Cancellation token. + /// A parsed . + Task ReadAsync(Stream source, ReadOptions options = null, CancellationToken cancellationToken = default); + + /// + /// Opens a .kpar file and returns an that keeps the underlying + /// ZIP container open for on-demand access to model and entry streams. + /// + /// + /// The absolute or relative file system path to the .kpar archive. + /// + /// + /// Optional controlling descriptor validation, + /// index validation, and other read-time behavior. + /// + /// + /// An containing the parsed + /// representation and providing methods to open model or entry streams on demand. + /// The caller is responsible for disposing the session. + /// + ArchiveSession Open(string filePath, ReadOptions options = null); + + /// + /// Opens a .kpar from an input stream and returns an that keeps the underlying + /// ZIP container open for on-demand content access. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// + /// An containing the parsed and providing + /// methods to open entry/model streams. The caller must dispose the session. + /// + ArchiveSession Open(Stream source, ReadOptions options = null); + + /// + /// Asynchronously opens a .kpar file and returns an that keeps the underlying + /// ZIP container open for on-demand content access. + /// + /// Absolute or relative path to the archive. + /// Optional read options. + /// Cancellation token. + /// + /// A task that returns an . The caller must dispose the session. + /// + Task OpenAsync(string filePath, ReadOptions options = null, CancellationToken cancellationToken = default); + + /// + /// Asynchronously opens a .kpar from an input stream and returns an that keeps the underlying + /// ZIP container open for on-demand content access. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// Cancellation token. + /// + /// A task that returns an . The caller must dispose the session. + /// + Task OpenAsync(Stream source, ReadOptions options = null, CancellationToken cancellationToken = default); + } +} diff --git a/SysML2.NET.Kpar/IWriter.cs b/SysML2.NET.Kpar/IWriter.cs new file mode 100644 index 000000000..b572b382d --- /dev/null +++ b/SysML2.NET.Kpar/IWriter.cs @@ -0,0 +1,78 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + using SysML2.NET.ModelInterchange; + + /// + /// Writes KerML Project Archives (.kpar) to a file path or stream. + /// + /// + /// A writer is expected to: + /// + /// Create a ZIP archive. + /// Write exactly one .project.json and one .meta.json at archive root. + /// Write one model interchange file per root namespace, with stable archive-relative paths. + /// + /// + public interface IWriter + { + /// + /// Writes a to a file path. + /// + /// Destination path (typically ending in .kpar). + /// The archive to write. + /// Optional write options. + void Write(string filePath, Archive archive, WriteOptions options = null); + + /// + /// Writes a to an output stream. + /// + /// Destination stream for the ZIP archive. + /// The archive to write. + /// If true, the writer does not dispose the stream. + /// Optional write options. + void Write(Stream destination, Archive archive, bool leaveOpen = false, WriteOptions options = null); + + /// + /// Writes a to a file path asynchronously. + /// + /// Destination path (typically ending in .kpar). + /// The package to write. + /// Optional write options. + /// Cancellation token. + Task WriteAsync(string filePath, Archive archive, WriteOptions options = null, CancellationToken cancellationToken = default); + + /// + /// Writes a to an output stream asynchronously. + /// + /// Destination stream for the ZIP archive. + /// The archive to write. + /// If true, the writer does not dispose the stream. + /// Optional write options. + /// Cancellation token. + Task WriteAsync(Stream destination, Archive archive, bool leaveOpen = false, WriteOptions options = null, CancellationToken cancellationToken = default); + } +} diff --git a/SysML2.NET.Kpar/ReadOptions.cs b/SysML2.NET.Kpar/ReadOptions.cs new file mode 100644 index 000000000..224d21432 --- /dev/null +++ b/SysML2.NET.Kpar/ReadOptions.cs @@ -0,0 +1,56 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System; + + /// + /// Options controlling .kpar reading behavior. + /// + public sealed class ReadOptions + { + /// + /// If true, the reader validates that .project.json and .meta.json exist at archive root and are unique. + /// + public bool ValidateRequiredDescriptors { get; set; } = true; + + /// + /// If true, the reader ensures the index entries in metadata resolve to existing model files. + /// + public bool ValidateIndexPaths { get; set; } = true; + + /// + /// If true, model file checksums declared in .meta.json + /// are computed and validated. + /// + public bool ValidateChecksums { get; set; } = false; + + /// + /// Controls how checksum mismatches are handled when + /// is enabled. + /// + /// + /// Default behavior is , + /// causing archive opening to fail immediately on integrity violation. + /// + public ChecksumFailureBehavior ChecksumFailureBehavior { get; set; } = ChecksumFailureBehavior.Throw; + } +} diff --git a/SysML2.NET.Kpar/Reader.cs b/SysML2.NET.Kpar/Reader.cs new file mode 100644 index 000000000..4c7062953 --- /dev/null +++ b/SysML2.NET.Kpar/Reader.cs @@ -0,0 +1,995 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + using System; + using System.Collections.Generic; + using System.Diagnostics; + using System.IO; + using System.IO.Compression; + using System.Linq; + using System.Text.Json; + using System.Threading; + using System.Threading.Tasks; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + using SysML2.NET.Kpar.Cryptography; + using SysML2.NET.Extensions.Utilities; + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.ModelInterchange; + + /// + /// Reads KerML Project Archives (.kpar) from a file path or stream. + /// + /// + /// A reader is expected to: + /// + /// Open the ZIP container. + /// Locate and parse .project.json and .meta.json at archive root. + /// Expose model interchange files (which may reside in subfolders) by their archive-relative paths. + /// + /// + public sealed class Reader : IReader + { + /// + /// The file name of the project descriptor located at the root of a .kpar archive. + /// + /// + /// The .project.json descriptor defines the logical project identity, + /// versioning, and structural metadata of the KerML archive. + /// + private const string ProjectDescriptorFileName = ".project.json"; + + /// + /// The file name of the metadata descriptor located at the root of a .kpar archive. + /// + /// + /// The .meta.json descriptor provides supplementary archive metadata, + /// including the model index and optional checksum information. + /// + private const string MetadataDescriptorFileName = ".meta.json"; + + /// + /// The default used when parsing descriptor files. + /// + /// + /// The reader: + /// + /// Skips JSON comments. + /// Allows trailing commas. + /// + /// These relaxed options improve interoperability with hand-authored or + /// tool-generated descriptor files while still enforcing structural correctness. + /// + private static readonly JsonReaderOptions DefaultReaderOptions = new() + { + CommentHandling = JsonCommentHandling.Skip, + AllowTrailingCommas = true + }; + + /// + /// The injected logger + /// + private readonly ILogger logger; + + /// + /// The injected + /// + private readonly IChecksumService checksumService; + + /// + /// Initializes a new instance of the class. + /// + /// + /// The (injected) + /// + /// + /// The injected logger + /// + public Reader(IChecksumService checksumService, ILogger logger = null) + { + this.checksumService = checksumService; + this.logger = logger ?? NullLogger.Instance; + } + + /// + /// Gets the path to the Kpar file that has been read + /// + public string Path { get; internal set; } + + /// + /// Reads a KerML project archive from a file path. + /// + /// + /// The path to the .kpar file. + /// + /// + /// Optional read options. + /// The parsed . + /// + public Archive Read(string filePath, ReadOptions options = null) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException("The path to the kpar file shall not be null or empty", nameof(filePath)); + } + + var sw = Stopwatch.StartNew(); + + this.logger.LogDebug("starting to read kpar at {Path}", filePath); + + options ??= new ReadOptions(); + using var fileStream = File.OpenRead(filePath); + + var archive = this.Read(fileStream, options); + + archive.Path = filePath; + + this.Path = filePath; + + this.logger.LogDebug("kpar at {Path} read in {ElapsedMilliseconds} [ms]", filePath, sw.ElapsedMilliseconds); + + return archive; + } + + /// + /// Reads a KerML project archive from a stream. + /// + /// + /// The stream containing the .kpar archive. + /// + /// + /// Optional read options. + /// + /// + /// The parsed . + /// + public Archive Read(Stream source, ReadOptions options = null) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source), "The source stream shall not be null"); + } + + var sw = Stopwatch.StartNew(); + + this.logger.LogDebug("starting to read kpar"); + + options ??= new ReadOptions(); + + using var zip = OpenZip(source); + var archive = ReadFromZip(zip, options); + + this.logger.LogDebug("kpar read in {ElapsedMilliseconds} [ms]", sw.ElapsedMilliseconds); + + return archive; + } + + /// + /// Reads a KerML project archive from a file path asynchronously. + /// + /// + /// The path to the .kpar file. + /// + /// + /// Optional read options. + /// + /// + /// The Cancellation token used to cancel the operation + /// + /// The parsed . + public async Task ReadAsync(string filePath, ReadOptions options = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrEmpty(filePath)) + { + throw new ArgumentException("The path to the kpar file shall not be null or empty", nameof(filePath)); + } + + var sw = Stopwatch.StartNew(); + + this.logger.LogDebug("starting to read kpar at {Path}", filePath); + + options ??= new ReadOptions(); + + await using var fs = File.OpenRead(filePath); + + var archive = await this.ReadAsync(fs, options, cancellationToken).ConfigureAwait(false); + + archive.Path = filePath; + + this.Path = filePath; + + this.logger.LogDebug("kpar at {Path} read in {ElapsedMilliseconds} [ms]", filePath, sw.ElapsedMilliseconds); + + return archive; + } + + + /// + /// Reads a KerML project archive from a stream asynchronously. + /// + /// + /// The stream containing the .kpar archive. + /// + /// Optional read options. + /// + /// + /// The Cancellation token used to cancel the operation + /// + /// + /// The parsed . + /// + public async Task ReadAsync(Stream source, ReadOptions options = null, CancellationToken cancellationToken = default) + { + if (source == null) + { + throw new ArgumentNullException(nameof(source), "The source stream shall not be null"); + } + + var sw = Stopwatch.StartNew(); + + this.logger.LogDebug("starting to read kpar"); + + options ??= new ReadOptions(); + + using var zip = OpenZip(source); + + var archive = await ReadFromZipAsync(zip, options, cancellationToken).ConfigureAwait(false); + + this.logger.LogDebug("kpar read in {ElapsedMilliseconds} [ms]", sw.ElapsedMilliseconds); + + return archive; + } + + /// + /// Opens a .kpar file and returns an that keeps the underlying + /// .kpar container open for on-demand access to model and entry streams. + /// + /// + /// The absolute or relative file system path to the .kpar archive. + /// + /// + /// Optional controlling descriptor validation, + /// index validation, and other read-time behavior. + /// + /// + /// An containing the parsed + /// representation and providing methods to open model or entry streams on demand. + /// The caller is responsible for disposing the session. + /// + public ArchiveSession Open(string filePath, ReadOptions options = null) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("The path to the kpar file shall not be null or empty", nameof(filePath)); + } + + options ??= new ReadOptions(); + + var fs = File.OpenRead(filePath); + + try + { + var archiveSession = this.Open(fs, options); + + archiveSession.Archive.Path = filePath; + + WireModelEntryOpeners(archiveSession); + + return archiveSession; + } + catch + { + fs.Dispose(); + throw; + } + } + + /// + /// Opens a .kpar from an input stream and returns an that keeps the underlying + /// .kpar container open for on-demand content access. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// + /// An containing the parsed and providing + /// methods to open entry/model streams. The caller must dispose the session. + /// + public ArchiveSession Open(Stream source, ReadOptions options = null) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source), "The source stream shall not be null"); + } + + options ??= new ReadOptions(); + + var zip = new ZipArchive(source, ZipArchiveMode.Read); + + try + { + var archive = ReadFromZip(zip, options); + + var archiveSession = new ArchiveSession(source, zip, archive); + + WireModelEntryOpeners(archiveSession); + + return archiveSession; + } + catch + { + zip.Dispose(); + source.Dispose(); + throw; + } + } + + /// + /// Asynchronously opens a .kpar file and returns an that keeps the underlying + /// .kpar container open for on-demand content access. + /// + /// Absolute or relative path to the archive. + /// Optional read options. + /// Cancellation token. + /// + /// A task that returns an . The caller must dispose the session. + /// + public async Task OpenAsync(string filePath, ReadOptions options = null, CancellationToken cancellationToken = default) + { + if (string.IsNullOrWhiteSpace(filePath)) + { + throw new ArgumentException("The path to the kpar file shall not be null or empty", nameof(filePath)); + } + + cancellationToken.ThrowIfCancellationRequested(); + options ??= new ReadOptions(); + + var fs = File.OpenRead(filePath); + + try + { + var archiveSession = await this.OpenAsync(fs, options, cancellationToken).ConfigureAwait(false); + + archiveSession.Archive.Path = filePath; + + WireModelEntryOpeners(archiveSession); + + return archiveSession; + } + catch + { + await fs.DisposeAsync().ConfigureAwait(false); + throw; + } + } + + /// + /// Asynchronously opens a .kpar from an input stream and returns an that keeps the underlying + /// ZIP container open for on-demand content access. + /// + /// The stream containing the ZIP archive. + /// Optional read options. + /// Cancellation token. + /// + /// A task that returns an . The caller must dispose the session. + /// + public async Task OpenAsync(Stream source, ReadOptions options = null, CancellationToken cancellationToken = default) + { + if (source is null) + { + throw new ArgumentNullException(nameof(source), "The source stream shall not be null"); + } + + cancellationToken.ThrowIfCancellationRequested(); + options ??= new ReadOptions(); + + var zip = new ZipArchive(source, ZipArchiveMode.Read); + + try + { + var archive = await ReadFromZipAsync(zip, options, cancellationToken).ConfigureAwait(false); + + var archiveSession = new ArchiveSession(source, zip, archive); + + WireModelEntryOpeners(archiveSession); + + return archiveSession; + } + catch + { + zip.Dispose(); + + await source.DisposeAsync().ConfigureAwait(false); + + throw; + } + } + + /// + /// Reads an from the specified + /// using the provided . + /// + /// + /// The containing the archive data to read. + /// + /// + /// The that control how the archive contents + /// are interpreted, filtered, and materialized. + /// + /// + /// An instance representing the logical contents + /// of the ZIP archive. + /// + private Archive ReadFromZip(ZipArchive zip, ReadOptions options) + { + var projectEntry = FindDescriptorEntryAtRoot(zip, ProjectDescriptorFileName, options); + var metaEntry = FindDescriptorEntryAtRoot(zip, MetadataDescriptorFileName, options); + + var interchangeProject = ReadJsonEntry(projectEntry, static (ref Utf8JsonReader r) => InterchangeProjectDeSerializer.DeSerialize(ref r)); + + var interchangeProjectMetadata = ReadJsonEntry(metaEntry, static (ref Utf8JsonReader r) => InterchangeProjectMetadataDeSerializer.DeSerialize(ref r)); + + var checksumMismatches = this.ValidateChecksums(zip, interchangeProjectMetadata, options); + + var modelEntries = BuildModelEntries(zip, interchangeProjectMetadata, options); + + return new Archive + { + Project = interchangeProject, + Metadata = interchangeProjectMetadata, + Models = modelEntries, + ChecksumMismatches = checksumMismatches + }; + } + + /// + /// Reads and builds the from a ZIP container (asynchronous path). + /// + /// The that is to be read + /// The used to read + /// The Cancellation token used to cancel the operation + private async Task ReadFromZipAsync(ZipArchive zip, ReadOptions options, CancellationToken cancellationToken) + { + var projectEntry = FindDescriptorEntryAtRoot(zip, ProjectDescriptorFileName, options); + var metaEntry = FindDescriptorEntryAtRoot(zip, MetadataDescriptorFileName, options); + + var project = await ReadJsonEntryAsync(projectEntry, static (ref Utf8JsonReader utf8JsonReader) => InterchangeProjectDeSerializer.DeSerialize(ref utf8JsonReader), + cancellationToken) + .ConfigureAwait(false); + + var meta = await ReadJsonEntryAsync(metaEntry, static (ref Utf8JsonReader utf8JsonReader) => InterchangeProjectMetadataDeSerializer.DeSerialize(ref utf8JsonReader), + cancellationToken) + .ConfigureAwait(false); + + var checksumMismatches = await this.ValidateChecksumsAsync(zip, meta, options, cancellationToken) + .ConfigureAwait(false); + + var models = BuildModelEntries(zip, meta, options); + + return new Archive + { + Project = project, + Metadata = meta, + Models = models, + ChecksumMismatches = checksumMismatches + }; + } + + /// + /// Opens a ZIP archive in read mode. + /// + /// + /// The to read from + /// + private static ZipArchive OpenZip(Stream source) + { + return new ZipArchive(source, ZipArchiveMode.Read); + } + + /// + /// Finds a descriptor entry with the given file name at archive root. + /// + /// The ZIP archive to search. + /// The descriptor file name (e.g. .project.json). + /// Read options controlling validation behavior. + /// The matching , or if not found and validation is disabled. + /// + /// Thrown when descriptor validation is enabled and the descriptor is missing or duplicated. + /// + private static ZipArchiveEntry FindDescriptorEntryAtRoot(ZipArchive zip, string descriptorFileName, ReadOptions options) + { + ZipArchiveEntry result = null; + + foreach (var entry in zip.Entries) + { + if (!IsRootEntry(entry)) + { + continue; + } + + if (string.Equals(entry.Name, descriptorFileName, StringComparison.OrdinalIgnoreCase)) + { + if (result != null && options?.ValidateRequiredDescriptors == true) + { + throw new InvalidDataException($"Multiple {descriptorFileName} files found at archive root."); + } + + result = entry; + } + } + + if (result == null && options?.ValidateRequiredDescriptors == true) + { + throw new InvalidDataException($"{descriptorFileName} descriptor not found at archive root."); + } + + return result; + } + + /// + /// Determines whether the specified represents + /// a file located at the root level of the archive (i.e., not contained + /// within a subdirectory and not a directory entry). + /// + /// + /// The to evaluate. + /// + /// + /// if the entry is a non-directory file located + /// at the archive root; otherwise, . + /// + private static bool IsRootEntry(ZipArchiveEntry entry) + { + if (string.IsNullOrEmpty(entry.Name)) + { + return false; + } + + return entry.FullName.IndexOf('/') < 0; + } + + /// + /// Builds the set of instances contained in the specified + /// , using the metadata index when available. + /// + /// + /// The containing the model interchange entries. + /// + /// + /// The describing the archive contents, + /// including any index of model entries, if present. + /// + /// + /// The controlling how entries are discovered and filtered. + /// + /// + /// An array of instances representing the model files + /// included in the archive. + /// + private static ModelEntry[] BuildModelEntries(ZipArchive zip, InterchangeProjectMetadata meta, ReadOptions options) + { + var map = meta.Index; + + var entryByPath = zip.Entries + .Where(e => !e.FullName.EndsWith("/", StringComparison.Ordinal)) + .ToDictionary(e => e.FullName.NormalizeZipPath(), e => e, StringComparer.Ordinal); + + entryByPath.RemoveWhereKeyEndsWith(ProjectDescriptorFileName); + entryByPath.RemoveWhereKeyEndsWith(MetadataDescriptorFileName); + + var result = new List(map.Count > 0 ? map.Count : entryByPath.Count); + + if (map.Count > 0) + { + foreach (var kvp in map) + { + var modelPath = kvp.Value.NormalizeZipPath(); + + if (options.ValidateIndexPaths && !entryByPath.TryGetValue(modelPath, out _)) + { + throw new InvalidDataException($"Metadata index entry '{kvp.Key}' points to missing archive path '{modelPath}'."); + } + + if (!entryByPath.ContainsKey(modelPath)) + { + continue; + } + + result.Add(CreateModelEntry(zip, modelPath)); + } + + return result.ToArray(); + } + + foreach (var path in entryByPath.Keys.OrderBy(p => p, StringComparer.Ordinal)) + { + result.Add(CreateModelEntry(zip, path)); + } + + return result.ToArray(); + } + + /// + /// Creates a instance for the specified + /// normalized ZIP entry path within the given . + /// + /// + /// The containing the entry from which the + /// is created. + /// + /// + /// The normalized ZIP entry path identifying the archive entry. + /// The path is expected to use forward slashes ('/') as directory + /// separators and must not contain a leading "./" segment. + /// + /// + /// A representing the archive entry located at + /// . + /// + private static ModelEntry CreateModelEntry(ZipArchive zip, string normalizedPath) + { + var contentType = QueryContentType(normalizedPath); + + return new ModelEntry + { + Path = normalizedPath, + ContentType = contentType, + + OpenReadAsync = async (_) => + { + var entry = zip.GetEntry(normalizedPath); + if (entry is null) + { + throw new FileNotFoundException($"kpar entry '{normalizedPath}' not found."); + } + + var s = entry.Open(); + return await Task.FromResult(s).ConfigureAwait(false); + } + }; + } + + /// + /// Determines the MIME content type associated with the file extension + /// of the specified path. + /// + /// + /// The file path or file name whose extension is used to resolve + /// the corresponding MIME content type. + /// + /// + /// A string representing the MIME content type (for example, + /// application/json or application/zip) associated + /// with the file extension of . + /// + /// + /// Thrown if is . + /// + /// + /// Thrown if is empty or does not contain + /// a valid file name or extension, depending on the implementation. + /// + private static string QueryContentType(string path) + { + if (path.EndsWith(".json", StringComparison.OrdinalIgnoreCase)) return "application/json"; + if (path.EndsWith(".xml", StringComparison.OrdinalIgnoreCase)) return "application/xml"; + if (path.EndsWith(".kerml", StringComparison.OrdinalIgnoreCase)) return "text/plain"; + return null; + } + + /// + /// Reads a JSON entry from the specified and parses + /// its UTF-8 payload using the supplied low-level parser delegate. + /// + /// + /// The type of object produced by the . + /// + /// + /// The representing the JSON file to read and parse. + /// + /// + /// A delegate that parses the JSON payload using a ref-based + /// and materializes an instance of + /// . + /// + /// + /// An instance of parsed from the JSON content + /// of the specified ZIP entry. + /// + private static T ReadJsonEntry(ZipArchiveEntry entry, FuncRefReader parser) + { + using var stream = entry.Open(); + var bytes = stream.ReadAllBytes(); + return ParseJsonBytes(bytes, parser); + } + + /// + /// Asynchronously reads a JSON entry from the specified + /// and parses its UTF-8 payload using the supplied low-level parser delegate. + /// + /// + /// The type of object produced by the . + /// + /// + /// The representing the JSON file to read and parse. + /// + /// + /// A delegate that parses the JSON payload using a ref-based + /// and materializes an instance of + /// . + /// + /// + /// A used to cancel the asynchronous + /// read operation. + /// + /// + /// A task that represents the asynchronous operation. The task result contains + /// the parsed instance of . + /// + private static async Task ReadJsonEntryAsync(ZipArchiveEntry entry, FuncRefReader parser, CancellationToken ct) + { + await using var stream = entry.Open(); + var bytes = await stream.ReadAllBytesAsync(ct).ConfigureAwait(false); + return ParseJsonBytes(bytes, parser); + } + + /// + /// Parses a JSON payload from the provided UTF-8 encoded byte span using + /// a low-level, ref-based parser delegate. + /// + /// + /// The type of object produced by the supplied . + /// + /// + /// A of UTF-8 encoded JSON bytes representing + /// a single JSON value. + /// + /// + /// A delegate that receives a (passed by reference) + /// positioned at the beginning of the JSON payload and is responsible for + /// parsing and materializing an instance of . + /// + /// + /// An instance of produced by the + /// from the supplied JSON payload. + /// + /// + /// This method creates a over the provided byte span + /// without additional allocations. The reader is forward-only and must be + /// fully consumed by the implementation according + /// to the parsing contract. + /// + /// The input is expected to contain valid UTF-8 encoded JSON. Validation and + /// structural correctness are enforced by . + /// + private static T ParseJsonBytes(ReadOnlySpan bytes, FuncRefReader parser) + { + var reader = new Utf8JsonReader(bytes, DefaultReaderOptions); + + if (!reader.Read()) + { + throw new JsonException("Unexpected end of JSON payload."); + } + + return parser(ref reader); + } + + /// + /// Wires delegates for all instances + /// in the specified so that entry content streams can be opened on demand. + /// + /// + /// The active that owns the underlying + /// and backing . + /// + private static void WireModelEntryOpeners(ArchiveSession session) + { + if (session?.Archive?.Models is null) return; + + foreach (var model in session.Archive.Models) + { + if (model is null) continue; + + var path = model.Path; + + model.OpenReadAsync = ct => + { + ct.ThrowIfCancellationRequested(); + + var s = session.OpenEntry(path); + return new ValueTask(Task.FromResult(s)); + }; + } + } + + /// + /// Validates model file checksums declared in the archive metadata against the actual + /// contents of the provided . + /// + /// + /// The open representing the underlying .kpar container. + /// The archive must remain valid and readable for the duration of validation. + /// + /// + /// The parsed instance originating from + /// .meta.json. This metadata may contain a checksum map describing expected + /// hash values for model entries. + /// + /// + /// The controlling checksum validation behavior. + /// Validation is performed only when is + /// set to . + /// The handling of detected mismatches is governed by + /// . + /// + /// + /// A read-only collection of instances representing + /// detected checksum inconsistencies. + /// + /// If checksum validation is disabled or the metadata does not declare any checksums, + /// an empty collection is returned. + /// + /// + private IReadOnlyList ValidateChecksums(ZipArchive zip, InterchangeProjectMetadata meta, ReadOptions options) + { + if (options?.ValidateChecksums != true) return Array.Empty(); + + var checksums = meta?.Checksum; + if (checksums is null || checksums.Count == 0) return Array.Empty(); + + var behavior = options.ChecksumFailureBehavior; + + var mismatches = this.checksumService.Validate(zip, meta, behavior); + + HydrateMismatchIndexKeys(meta, mismatches); + + return mismatches; + } + + /// + /// Asynchronously validates all model file checksums declared in the specified + /// against the actual contents of the + /// provided . + /// + /// + /// The open representing the .kpar container. + /// The archive must remain open for the duration of the validation process. + /// + /// + /// The parsed containing the + /// checksum declarations from .meta.json. + /// + /// + /// The controlling checksum validation behavior. + /// This must be non-null. The flag + /// determines whether validation is enabled, and + /// determines how mismatches + /// are handled. + /// + /// + /// A that can be used to cancel the asynchronous + /// validation operation. + /// + /// + /// A task representing the asynchronous validation operation. The task result + /// contains a read-only list of detected instances. + /// If no mismatches are detected, an empty list is returned. + /// + /// + /// Thrown if , , or + /// is . + /// + /// + /// Thrown if a checksum declaration refers to a ZIP entry that does not exist + /// within the archive. + /// + /// + /// Thrown when one or more checksum mismatches are detected and + /// is set to + /// . + /// + private async Task> ValidateChecksumsAsync(ZipArchive zip, InterchangeProjectMetadata meta, ReadOptions options, CancellationToken ct) + { + if (options?.ValidateChecksums != true) return Array.Empty(); + + var checksums = meta?.Checksum; + if (checksums is null || checksums.Count == 0) return Array.Empty(); + + var behavior = options.ChecksumFailureBehavior; + + var mismatches = await this.checksumService.ValidateAsync(zip, meta, behavior, ct).ConfigureAwait(false); + + HydrateMismatchIndexKeys(meta, mismatches); + + return mismatches; + } + + /// + /// Enriches the specified collection of instances + /// with metadata index keys derived from the provided + /// . + /// + /// + /// The containing the + /// index map that associates logical model index keys + /// (for example "Base") with archive-relative paths. + /// + /// + /// The collection of instances + /// produced during checksum validation. + /// + private static void HydrateMismatchIndexKeys(InterchangeProjectMetadata meta, IReadOnlyList mismatches) + { + if (mismatches is null || mismatches.Count == 0) return; + + var index = meta?.Index; + if (index is null || index.Count == 0) return; + + var byPath = new Dictionary(StringComparer.Ordinal); + foreach (var kvp in index) + { + var p = kvp.Value?.NormalizeZipPath(); + if (string.IsNullOrWhiteSpace(p)) continue; + + if (!byPath.ContainsKey(p)) + { + byPath[p] = kvp.Key; + } + } + + foreach (var m in mismatches) + { + if (m is null) continue; + + if (!string.IsNullOrWhiteSpace(m.Path) && byPath.TryGetValue(m.Path.NormalizeZipPath(), out var key)) + { + m.IndexKey = key; + } + } + } + + /// + /// Represents a delegate that parses a JSON value using a forward-only, + /// ref-based . + /// + /// + /// The type of the object produced from the JSON input. + /// + /// + /// A reference to the positioned at the beginning + /// of the JSON value to be parsed. The reader is passed by reference because it is a + /// ref struct and because parsing advances its internal state. + /// + /// + /// An instance of created from the JSON value read + /// from the provided . + /// + /// + /// Implementations are expected to fully consume the JSON value they parse, + /// leaving the positioned on the final token of that value. + /// The caller remains responsible for advancing the reader beyond that token, + /// if required. + /// + private delegate T FuncRefReader(ref Utf8JsonReader reader); + } +} diff --git a/SysML2.NET.Kpar/SysML2.NET.Kpar.csproj b/SysML2.NET.Kpar/SysML2.NET.Kpar.csproj new file mode 100644 index 000000000..020b0d160 --- /dev/null +++ b/SysML2.NET.Kpar/SysML2.NET.Kpar.csproj @@ -0,0 +1,45 @@ + + + + + + netstandard2.1 + 12.0 + 0.18.0 + A .NET implementation of the OMG SysML v2 specification. + SysML2.NET.Kpar + Starion Group S.A. + Copyright © Starion Group S.A. + Apache-2.0 + https://github.com/STARIONGROUP/SysML2.NET.git + Git + Sam Gerené + true + + [Initial] Version + + cdp4-icon.png + README.md + true + true + + + + + + + + + + + + + + + + + + + + + diff --git a/SysML2.NET.Kpar/WriteOptions.cs b/SysML2.NET.Kpar/WriteOptions.cs new file mode 100644 index 000000000..c3aaf84aa --- /dev/null +++ b/SysML2.NET.Kpar/WriteOptions.cs @@ -0,0 +1,48 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Kpar +{ + /// + /// Options controlling .kpar writing behavior. + /// + public sealed class WriteOptions + { + /// + /// If true, the writer normalizes paths to use forward slashes and strips leading slashes. + /// + public bool NormalizePaths { get; set; } = true; + + /// + /// If true, the writer fails if any descriptor would be written outside the archive root. + /// + public bool EnforceDescriptorsAtRoot { get; set; } = true; + + /// + /// Optional: compute and populate metadata checksums for some or all model files. + /// + public bool ComputeChecksums { get; set; } = false; + + /// + /// Optional checksum algorithm name (e.g., SHA256) if is enabled. + /// + public string ChecksumAlgorithm { get; set; } + } +} diff --git a/SysML2.NET.Serializer.Json.Tests/Data/.meta.json b/SysML2.NET.Serializer.Json.Tests/Data/.meta.json new file mode 100644 index 000000000..565d6eb6e --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/Data/.meta.json @@ -0,0 +1,90 @@ +{ + "index": { + "Base": "Base.kerml", + "Clocks": "Clocks.kerml", + "ControlPerformances": "ControlPerformances.kerml", + "FeatureReferencingPerformances": "FeatureReferencingPerformances.kerml", + "KerML": "KerML.kerml", + "Links": "Links.kerml", + "Metaobjects": "Metaobjects.kerml", + "Objects": "Objects.kerml", + "Observation": "Observation.kerml", + "Occurrences": "Occurrences.kerml", + "Performances": "Performances.kerml", + "SpatialFrames": "SpatialFrames.kerml", + "StatePerformances": "StatePerformances.kerml", + "Transfers": "Transfers.kerml", + "TransitionPerformances": "TransitionPerformances.kerml", + "Triggers": "Triggers.kerml" + }, + "created": "2025-03-13T00:00:00Z", + "metamodel": "https://www.omg.org/spec/KerML/20250201", + "includesDerived": false, + "includesImplied": false, + "checksum": { + "Triggers.kerml": { + "value": "124cad3625935e078d1363e6100ee12537ca9c51445a18108e056db8b4885609", + "algorithm": "SHA256" + }, + "ControlPerformances.kerml": { + "value": "31385be7dca94bd0538f011d5c8f7925626d54f96970769f0fdb28b2186a9a03", + "algorithm": "SHA256" + }, + "Transfers.kerml": { + "value": "fa40b483a7834d89f07aad0f6f57e79244adc2a58b4396c4734bddeb297d7c46", + "algorithm": "SHA256" + }, + "Objects.kerml": { + "value": "9057e2781fe8793d5108973c0647318caa26310be6231c6380152a4cbc894c25", + "algorithm": "SHA256" + }, + "Metaobjects.kerml": { + "value": "983dbd85a4b183d8859326ee512fc59d991fb98a115e009e72fad21d1f9d1685", + "algorithm": "SHA256" + }, + "Performances.kerml": { + "value": "fd965e184b300737a192530de0c800cdbee236cb6220612f370400da21dfb327", + "algorithm": "SHA256" + }, + "StatePerformances.kerml": { + "value": "f02fb7e8de58f4304c95c575ee1bcb7d271d621ce8e336ce36ea80a4e956c3da", + "algorithm": "SHA256" + }, + "Base.kerml": { + "value": "56df84cda67f62c63d4e79e2786fc26046cfa361a958c4fcf0843d32a5707e09", + "algorithm": "SHA256" + }, + "Observation.kerml": { + "value": "6bc57a73c43af6f61201b6eb659024a9f08f974643eb5a101e068e3637761ee4", + "algorithm": "SHA256" + }, + "TransitionPerformances.kerml": { + "value": "1ce78437c817c8359a2cad43e8e72b23dd32b81d2a69dc1126c803fae72aae70", + "algorithm": "SHA256" + }, + "FeatureReferencingPerformances.kerml": { + "value": "b6f9e5349c7c7f393591c0334c3bec86f1766b3e37209819179310c2f8fe1fb7", + "algorithm": "SHA256" + }, + "KerML.kerml": { + "value": "8fdf4b7416e981c895cd74b75dc14b18091d13cbcaff7cdded6f9c23e2483d58", + "algorithm": "SHA256" + }, + "Occurrences.kerml": { + "value": "b3a62ce0bc3a4f7e667102b4c2f68a4928ca8efeda425c6a4c8bdeadfbc9bbc1", + "algorithm": "SHA256" + }, + "Clocks.kerml": { + "value": "960ac0884935e308beea55c78ed11b6946c37a386eb7958ef2c913aa275ae4c7", + "algorithm": "SHA256" + }, + "Links.kerml": { + "value": "dcf3c002717cb91f8e16f1890fdf5526f4e178ada898a189621c7d0c24b5ddc0", + "algorithm": "SHA256" + }, + "SpatialFrames.kerml": { + "value": "2a7790ebc2afacbd64eb781567906921e38eff385c917be03b090b8289353de7", + "algorithm": "SHA256" + } + } +} \ No newline at end of file diff --git a/SysML2.NET.Serializer.Json.Tests/Data/.project.json b/SysML2.NET.Serializer.Json.Tests/Data/.project.json new file mode 100644 index 000000000..5e92d19b6 --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/Data/.project.json @@ -0,0 +1,19 @@ +{ + "name": "Kernel Semantic Library", + "description": "Standard semantic library for the Kernel Modeling Language (KerML)", + "version": "1.0.0", + "license": "LGPL", + "maintainer": ["OMG"], + "website": "https://www.omg.org/spec/KerML", + "topic": ["Kerml", "OMG"], + "usage": [ + { + "resource": "https://www.omg.org/spec/KerML/20250201/Data-Type-Library.kpar", + "versionConstraint": "1.0.0" + }, + { + "resource": "https://www.omg.org/spec/KerML/20250201/Function-Library.kpar", + "versionConstraint": "1.0.0" + } + ] +} \ No newline at end of file diff --git a/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeChecksumDeserializerTestFixture.cs b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeChecksumDeserializerTestFixture.cs new file mode 100644 index 000000000..2114d1f78 --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeChecksumDeserializerTestFixture.cs @@ -0,0 +1,201 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.Tests.ModelInterchange +{ + using System; + using System.Buffers; + using System.Text; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + using NUnit.Framework; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.ModelInterchange; + + [TestFixture] + public class InterchangeChecksumDeserializerTestFixture + { + [Test] + public void Verify_that_DeSerialize_reads_value_and_algorithm_from_valid_object() + { + var json = @"{""value"":""ABCDEF0123456789"",""algorithm"":""SHA256""}"; + + var checksum = DeserializeFromJson(json); + + Assert.That(checksum, Is.Not.Null); + Assert.That(checksum.Value, Is.EqualTo("ABCDEF0123456789")); + Assert.That(checksum.Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + } + + [Test] + public void Verify_that_DeSerialize_tolerates_null_value_and_sets_empty_string() + { + var json = @"{""value"":null,""algorithm"":""SHA1""}"; + + var checksum = DeserializeFromJson(json); + + Assert.That(checksum.Value, Is.EqualTo(string.Empty)); + Assert.That(checksum.Algorithm, Is.EqualTo(ChecksumKind.SHA1)); + } + + [Test] + public void Verify_that_DeSerialize_tolerates_null_algorithm_and_leaves_default() + { + var json = @"{""value"":""deadbeef"",""algorithm"":null}"; + + var checksum = DeserializeFromJson(json); + + Assert.That(checksum.Value, Is.EqualTo("deadbeef")); + Assert.That(checksum.Algorithm, Is.EqualTo(default(ChecksumKind))); + } + + [Test] + public void Verify_that_DeSerialize_skips_unknown_properties_and_continues() + { + var json = @"{ + ""value"":""1234"", + ""unknownString"":""x"", + ""unknownObj"":{""a"":1,""b"":[true,false]}, + ""unknownArr"":[1,2,3], + ""algorithm"":""MD5"" + }"; + + var checksum = DeserializeFromJson(json, NullLoggerFactory.Instance); + + Assert.That(checksum.Value, Is.EqualTo("1234")); + Assert.That(checksum.Algorithm, Is.EqualTo(ChecksumKind.MD5)); + } + + [Test] + public void Verify_that_DeSerialize_skips_non_string_algorithm_and_leaves_default() + { + var json = @"{""value"":""1234"",""algorithm"":123}"; + + var checksum = DeserializeFromJson(json); + + Assert.That(checksum.Value, Is.EqualTo("1234")); + Assert.That(checksum.Algorithm, Is.EqualTo(default(ChecksumKind))); + } + + [Test] + public void Verify_that_DeSerialize_reads_algorithm_from_multi_segment_value_sequence() + { + // Force HasValueSequence = true by using a segmented ReadOnlySequence. + // We craft JSON so that the algorithm token ("SHA256") spans segments. + + var utf8 = Encoding.UTF8.GetBytes(@"{""value"":""v"",""algorithm"":""SHA256""}"); + + // Split *inside* the algorithm string bytes so the ValueSequence is multi-segment. + // Find the "SHA256" bytes and split after "SHA". + var needle = Encoding.UTF8.GetBytes(@"""SHA256"""); + var idx = IndexOf(utf8, needle); + Assert.That(idx, Is.GreaterThanOrEqualTo(0), "Test setup failed: could not find algorithm token."); + + // Position of actual letters starts after the first quote + var startLetters = idx + 1; + var splitAt = startLetters + 3; // "SHA" | "256" + + var sequence = CreateMultiSegmentSequence(utf8, splitAt); + + var reader = new Utf8JsonReader(sequence, isFinalBlock: true, state: default); + + // Move to StartObject + Assert.That(reader.Read(), Is.True); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.StartObject)); + + var checksum = InterchangeChecksumDeserializer.DeSerialize(ref reader); + + Assert.That(checksum.Value, Is.EqualTo("v")); + Assert.That(checksum.Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + } + + private static InterchangeChecksum DeserializeFromJson(string json, ILoggerFactory loggerFactory = null) + { + var bytes = Encoding.UTF8.GetBytes(json); + var reader = new Utf8JsonReader(bytes, isFinalBlock: true, state: default); + + // Position the reader on StartObject (as required by the deserializer) + Assert.That(reader.Read(), Is.True, "JSON did not yield any tokens."); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.StartObject), "Reader not positioned on StartObject."); + + return InterchangeChecksumDeserializer.DeSerialize(ref reader, loggerFactory); + } + + private static int IndexOf(byte[] haystack, byte[] needle) + { + if (needle.Length == 0) return 0; + for (var i = 0; i <= haystack.Length - needle.Length; i++) + { + var match = true; + for (var j = 0; j < needle.Length; j++) + { + if (haystack[i + j] != needle[j]) + { + match = false; + break; + } + } + if (match) return i; + } + return -1; + } + + private static ReadOnlySequence CreateMultiSegmentSequence(byte[] data, int splitIndex) + { + if (splitIndex <= 0 || splitIndex >= data.Length) + { + throw new ArgumentOutOfRangeException(nameof(splitIndex)); + } + + var firstMem = new ReadOnlyMemory(data, 0, splitIndex); + var secondMem = new ReadOnlyMemory(data, splitIndex, data.Length - splitIndex); + + var first = new BufferSegment(firstMem); + var last = first.Append(secondMem); + + return new ReadOnlySequence(first, 0, last, last.Memory.Length); + } + + /// + /// Minimal ReadOnlySequence segment helper to build multi-segment sequences. + /// + private sealed class BufferSegment : ReadOnlySequenceSegment + { + public BufferSegment(ReadOnlyMemory memory) + { + Memory = memory; + } + + public BufferSegment Append(ReadOnlyMemory memory) + { + var segment = new BufferSegment(memory) + { + RunningIndex = RunningIndex + Memory.Length + }; + + Next = segment; + return segment; + } + } + } +} diff --git a/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectDeSerializerTestFixture.cs b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectDeSerializerTestFixture.cs new file mode 100644 index 000000000..44d419356 --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectDeSerializerTestFixture.cs @@ -0,0 +1,88 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.Tests.ModelInterchange +{ + using System.IO; + using System.Text.Json; + + using SysML2.NET.Serializer.Json.ModelInterchange; + using SysML2.NET.ModelInterchange; + + using NUnit.Framework; + + /// + /// Suite of tests for the + /// + [TestFixture] + public class InterchangeProjectDeSerializerTestFixture + { + [Test] + public void Verify_that_project_dot_json_file_can_be_deserialized() + { + var fileName = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Data", ".project.json"); + using var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read); + + var json = File.ReadAllBytes(fileName); + + var interchangeProject = DeserializeFromJson(json); + + using (Assert.EnterMultipleScope()) + { + Assert.That(interchangeProject, Is.Not.Null); + Assert.That(interchangeProject.Name, Is.EqualTo("Kernel Semantic Library")); + Assert.That(interchangeProject.Description, Is.EqualTo("Standard semantic library for the Kernel Modeling Language (KerML)")); + Assert.That(interchangeProject.Version, Is.EqualTo("1.0.0")); + Assert.That(interchangeProject.License, Is.EqualTo("LGPL")); + Assert.That(interchangeProject.Maintainer.Count, Is.EqualTo(1)); + Assert.That(interchangeProject.Maintainer[0], Is.EqualTo("OMG")); + Assert.That(interchangeProject.Website, Is.EqualTo("https://www.omg.org/spec/KerML")); + Assert.That(interchangeProject.Topic.Count, Is.EqualTo(2)); + Assert.That(interchangeProject.Topic[0], Is.EqualTo("Kerml")); + Assert.That(interchangeProject.Topic[1], Is.EqualTo("OMG")); + Assert.That(interchangeProject.Usage.Count, Is.EqualTo(2)); + Assert.That(interchangeProject.Usage[0].Resource, Is.EqualTo("https://www.omg.org/spec/KerML/20250201/Data-Type-Library.kpar")); + Assert.That(interchangeProject.Usage[0].VersionConstraint, Is.EqualTo("1.0.0")); + Assert.That(interchangeProject.Usage[1].Resource, Is.EqualTo("https://www.omg.org/spec/KerML/20250201/Function-Library.kpar")); + Assert.That(interchangeProject.Usage[1].VersionConstraint, Is.EqualTo("1.0.0")); + } + } + + /// + /// Deserializes an instance from a UTF-8 encoded JSON payload. + /// + /// + /// A byte array containing a valid UTF-8 encoded JSON representation of an + /// document. + /// + /// + /// The deserialized instance. + /// + private static InterchangeProject DeserializeFromJson(byte[] json) + { + var reader = new Utf8JsonReader(json, isFinalBlock: true, state: default); + + Assert.That(reader.Read(), Is.True, "JSON did not yield any tokens."); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.StartObject), "Reader not positioned on StartObject."); + + return InterchangeProjectDeSerializer.DeSerialize(ref reader); + } + } +} diff --git a/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectMetadataDeSerializerTestFixture.cs b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectMetadataDeSerializerTestFixture.cs new file mode 100644 index 000000000..4358ca471 --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/ModelInterchange/InterchangeProjectMetadataDeSerializerTestFixture.cs @@ -0,0 +1,100 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.Tests.ModelInterchange +{ + using System; + using System.IO; + using System.Text.Json; + + using SysML2.NET.Serializer.Json.ModelInterchange; + using SysML2.NET.ModelInterchange; + + using NUnit.Framework; + + /// + /// Suite of tests for the + /// + [TestFixture] + public class InterchangeProjectMetadataDeSerializerTestFixture + { + [Test] + public void Verify_that_meta_dot_json_file_can_be_deserialized() + { + var fileName = Path.Combine(TestContext.CurrentContext.WorkDirectory, "Data", ".meta.json"); + using var stream = new FileStream(fileName, FileMode.Open, FileAccess.Read); + + var json = File.ReadAllBytes(fileName); + + var interchangeProjectMetadata = DeserializeFromJson(json); + + Assert.That(interchangeProjectMetadata, Is.Not.Null); + + Assert.Multiple(() => + { + Assert.That(interchangeProjectMetadata.Metamodel, Is.EqualTo("https://www.omg.org/spec/KerML/20250201")); + Assert.That(interchangeProjectMetadata.Created, Is.EqualTo(DateTimeOffset.Parse("2025-03-13T00:00:00Z", null, System.Globalization.DateTimeStyles.AssumeUniversal))); + Assert.That(interchangeProjectMetadata.IncludesDerived, Is.False); + Assert.That(interchangeProjectMetadata.IncludesImplied, Is.False); + + Assert.That(interchangeProjectMetadata.Index, Is.Not.Null); + Assert.That(interchangeProjectMetadata.Checksum, Is.Not.Null); + + Assert.That(interchangeProjectMetadata.Index, Has.Count.EqualTo(16)); + Assert.That(interchangeProjectMetadata.Index["Base"], Is.EqualTo("Base.kerml")); + Assert.That(interchangeProjectMetadata.Index["KerML"], Is.EqualTo("KerML.kerml")); + Assert.That(interchangeProjectMetadata.Index["Triggers"], Is.EqualTo("Triggers.kerml")); + + Assert.That(interchangeProjectMetadata.Checksum, Has.Count.EqualTo(16)); + + Assert.That(interchangeProjectMetadata.Checksum.ContainsKey("Base.kerml"), Is.True); + Assert.That(interchangeProjectMetadata.Checksum["Base.kerml"].Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + Assert.That(interchangeProjectMetadata.Checksum["Base.kerml"].Value, Is.EqualTo("56df84cda67f62c63d4e79e2786fc26046cfa361a958c4fcf0843d32a5707e09")); + + Assert.That(interchangeProjectMetadata.Checksum.ContainsKey("Triggers.kerml"), Is.True); + Assert.That(interchangeProjectMetadata.Checksum["Triggers.kerml"].Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + Assert.That(interchangeProjectMetadata.Checksum["Triggers.kerml"].Value, Is.EqualTo("124cad3625935e078d1363e6100ee12537ca9c51445a18108e056db8b4885609")); + + Assert.That(interchangeProjectMetadata.Checksum.ContainsKey("SpatialFrames.kerml"), Is.True); + Assert.That(interchangeProjectMetadata.Checksum["SpatialFrames.kerml"].Algorithm, Is.EqualTo(ChecksumKind.SHA256)); + Assert.That(interchangeProjectMetadata.Checksum["SpatialFrames.kerml"].Value, Is.EqualTo("2a7790ebc2afacbd64eb781567906921e38eff385c917be03b090b8289353de7")); + }); + } + + /// + /// Deserializes an instance from a UTF-8 encoded JSON payload. + /// + /// + /// The UTF-8 encoded JSON byte array representing a .meta.json interchange metadata document. + /// + /// + /// The deserialized instance. + /// + private static InterchangeProjectMetadata DeserializeFromJson(byte[] json) + { + var reader = new Utf8JsonReader(json, isFinalBlock: true, state: default); + + Assert.That(reader.Read(), Is.True, "JSON did not yield any tokens."); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.StartObject), "Reader not positioned on StartObject."); + + return InterchangeProjectMetadataDeSerializer.DeSerialize(ref reader); + } + } +} \ No newline at end of file diff --git a/SysML2.NET.Serializer.Json.Tests/SysML2.NET.Serializer.Json.Tests.csproj b/SysML2.NET.Serializer.Json.Tests/SysML2.NET.Serializer.Json.Tests.csproj index 141fc1756..a25aaaabb 100644 --- a/SysML2.NET.Serializer.Json.Tests/SysML2.NET.Serializer.Json.Tests.csproj +++ b/SysML2.NET.Serializer.Json.Tests/SysML2.NET.Serializer.Json.Tests.csproj @@ -67,6 +67,12 @@ Always + + Always + + + Always + diff --git a/SysML2.NET.Serializer.Json.Tests/Utility/Utf8JsonReaderHelperTestFixture.cs b/SysML2.NET.Serializer.Json.Tests/Utility/Utf8JsonReaderHelperTestFixture.cs new file mode 100644 index 000000000..f185f774a --- /dev/null +++ b/SysML2.NET.Serializer.Json.Tests/Utility/Utf8JsonReaderHelperTestFixture.cs @@ -0,0 +1,275 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.Utility.Tests +{ + using System; + using System.Globalization; + using System.Text; + using System.Text.Json; + + using NUnit.Framework; + + using SysML2.NET.Serializer.Json.Utility; + + [TestFixture] + public sealed class Utf8JsonReaderHelperTestFixture + { + [Test] + public void Verify_that_Expect_does_not_throw_when_token_matches() + { + var reader = CreateReader("true"); + + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.True); + + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.True)); + } + + [Test] + public void Verify_that_Expect_throws_when_token_does_not_match() + { + Assert.That( + InvokeExpectWithFalse, + Throws.TypeOf().With.Message.Contains("Expected")); + + static void InvokeExpectWithFalse() + { + var reader = CreateReader("false"); + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.True); + } + } + + [Test] + public void Verify_that_ReadStringOrNull_returns_null_when_token_is_null() + { + var reader = CreateReader("null"); + + var value = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + + Assert.That(value, Is.Null); + } + + [Test] + public void Verify_that_ReadStringOrNull_returns_string_when_token_is_string() + { + var reader = CreateReader("\"hello\""); + + var value = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + + Assert.That(value, Is.EqualTo("hello")); + } + + [Test] + public void Verify_that_ReadStringOrNull_throws_when_token_is_not_string_or_null() + { + Assert.That( + InvokeReadStringOrNullWithNumber, + Throws.TypeOf().With.Message.Contains("Expected string or null")); + + static void InvokeReadStringOrNullWithNumber() + { + var reader = CreateReader("123"); + _ = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + } + } + + [Test] + public void Verify_that_ReadBoolOrNull_returns_null_when_token_is_null() + { + var reader = CreateReader("null"); + + var value = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + + Assert.That(value, Is.Null); + } + + [Test] + public void Verify_that_ReadBoolOrNull_returns_true_when_token_is_true() + { + var reader = CreateReader("true"); + + var value = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + + Assert.That(value, Is.True); + } + + [Test] + public void Verify_that_ReadBoolOrNull_returns_false_when_token_is_false() + { + var reader = CreateReader("false"); + + var value = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + + Assert.That(value, Is.False); + } + + [Test] + public void Verify_that_ReadBoolOrNull_throws_when_token_is_not_bool_or_null() + { + Assert.That( + InvokeReadBoolOrNullWithString, + Throws.TypeOf().With.Message.Contains("Expected bool or null")); + + static void InvokeReadBoolOrNullWithString() + { + var reader = CreateReader("\"true\""); + _ = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + } + } + + [Test] + public void Verify_that_ReadDateTimeIso8601_parses_roundtrip_kind() + { + // Use a value that encodes offset to make RoundtripKind relevant. + var iso = "2026-02-15T12:34:56.789+01:00"; + var reader = CreateReader($"\"{iso}\""); + + var dt = Utf8JsonReaderHelper.ReadDateTimeIso8601(ref reader); + + var expected = DateTime.Parse(iso, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + Assert.That(dt, Is.EqualTo(expected)); + } + + [TestCase("null")] + [TestCase("\"\"")] + [TestCase("\" \"")] + public void Verify_that_ReadDateTimeIso8601_throws_when_value_is_null_or_whitespace(string json) + { + Assert.That( + () => InvokeReadDateTimeIso8601(json), + Throws.TypeOf().With.Message.Contains("Expected ISO 8601 date-time string")); + } + + private static void InvokeReadDateTimeIso8601(string json) + { + var reader = CreateReader(json); + _ = Utf8JsonReaderHelper.ReadDateTimeIso8601(ref reader); + } + + [Test] + public void Verify_that_ReadDateTimeIso8601_throws_when_value_is_not_a_valid_datetime() + { + Assert.That( + InvokeReadDateTimeIso8601WithInvalidDate, + Throws.TypeOf()); + + static void InvokeReadDateTimeIso8601WithInvalidDate() + { + var reader = CreateReader("\"not-a-date\""); + _ = Utf8JsonReaderHelper.ReadDateTimeIso8601(ref reader); + } + } + + [Test] + public void Verify_that_ReadUriOrNull_returns_null_when_token_is_null() + { + var reader = CreateReader("null"); + + var uri = Utf8JsonReaderHelper.ReadUriOrNull(ref reader); + + Assert.That(uri, Is.Null); + } + + [TestCase("\"\"")] + [TestCase("\" \"")] + public void Verify_that_ReadUriOrNull_returns_null_when_string_is_empty_or_whitespace(string json) + { + var reader = CreateReader(json); + + var uri = Utf8JsonReaderHelper.ReadUriOrNull(ref reader); + + Assert.That(uri, Is.Null); + } + + [Test] + public void Verify_that_ReadUriOrNull_parses_absolute_uri() + { + var reader = CreateReader("\"https://example.com/a/b\""); + + var uri = Utf8JsonReaderHelper.ReadUriOrNull(ref reader); + + Assert.That(uri, Is.Not.Null); + Assert.That(uri.IsAbsoluteUri, Is.True); + Assert.That(uri.AbsoluteUri, Is.EqualTo("https://example.com/a/b")); + } + + [Test] + public void Verify_that_ReadUriOrNull_parses_relative_uri() + { + var reader = CreateReader("\"./folder/file.kerml\""); + + var uri = Utf8JsonReaderHelper.ReadUriOrNull(ref reader); + + Assert.That(uri, Is.Not.Null); + Assert.That(uri.IsAbsoluteUri, Is.False); + Assert.That(uri.OriginalString, Is.EqualTo("./folder/file.kerml")); + } + + [Test] + public void Verify_that_SkipValue_skips_nested_value_and_positions_reader_on_next_token() + { + // We position the reader on the value token (StartObject) of property "a", then skip it, + // and validate that we end up on the PropertyName token for "b". + var json = "{\"a\":{\"x\":[1,2,{\"y\":3}]},\"b\":42}"; + var reader = CreateReader(json); + + // StartObject (root) + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.StartObject); + + // PropertyName "a" + Assert.That(reader.Read(), Is.True); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.PropertyName)); + Assert.That(reader.GetString(), Is.EqualTo("a")); + + // Value for "a" (StartObject) + Assert.That(reader.Read(), Is.True); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.StartObject)); + + Utf8JsonReaderHelper.SkipValue(ref reader); + + // After Skip(), reader is positioned on the last token of the skipped value (EndObject). + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.EndObject)); + + // Next token should be PropertyName "b" + Assert.That(reader.Read(), Is.True); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.PropertyName)); + Assert.That(reader.GetString(), Is.EqualTo("b")); + + // And its value + Assert.That(reader.Read(), Is.True); + Assert.That(reader.TokenType, Is.EqualTo(JsonTokenType.Number)); + Assert.That(reader.GetInt32(), Is.EqualTo(42)); + } + + private static Utf8JsonReader CreateReader(string json) + { + var bytes = Encoding.UTF8.GetBytes(json); + + var reader = new Utf8JsonReader(bytes, new JsonReaderOptions + { + CommentHandling = JsonCommentHandling.Disallow, + AllowTrailingCommas = false + }); + + Assert.That(reader.Read(), Is.True, "Test JSON must produce at least one token."); + return reader; + } + } +} diff --git a/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeChecksumDeserializer.cs b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeChecksumDeserializer.cs new file mode 100644 index 000000000..f6e29fa48 --- /dev/null +++ b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeChecksumDeserializer.cs @@ -0,0 +1,134 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.ModelInterchange +{ + using System; + using System.Buffers; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.Utility; + + /// + /// Provides low-level JSON deserialization support for objects. + /// + public static class InterchangeChecksumDeserializer + { + /// + /// Deserializes a JSON object representing an + /// instance from the current position of a . + /// + /// + /// The positioned on a + /// token that begins an JSON object. + /// + /// + /// Optional used to create a logger for diagnostics. + /// + /// + /// A populated instance. + /// + /// + /// Thrown when the JSON structure does not conform to the expected object-based + /// representation of a checksum descriptor. + /// + /// + /// Recognized properties: + /// + /// + /// value — mandatory hexadecimal checksum value. + /// + /// + /// algorithm — mandatory checksum algorithm identifier (e.g. SHA256). + /// + /// + /// All other properties are ignored. + /// + public static InterchangeChecksum DeSerialize(ref Utf8JsonReader reader, ILoggerFactory loggerFactory = null) + { + var logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(nameof(InterchangeChecksum)); + + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.StartObject); + + var checksum = new InterchangeChecksum(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in checksum object."); + } + + if (reader.ValueTextEquals("value"u8)) + { + reader.Read(); + checksum.Value = Utf8JsonReaderHelper.ReadStringOrNull(ref reader) ?? string.Empty; + continue; + } + + if (reader.ValueTextEquals("algorithm"u8)) + { + reader.Read(); + + if (reader.TokenType == JsonTokenType.String) + { + if (reader.HasValueSequence) + { + checksum.Algorithm = ChecksumKindProvider.Parse(reader.ValueSequence); + } + else + { + checksum.Algorithm = ChecksumKindProvider.Parse(reader.ValueSpan); + } + } + else if (reader.TokenType == JsonTokenType.Null) + { + // Leave default; tolerate missing algorithm for forward compatibility. + } + else + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + continue; + } + + // Unknown property => skip value for forward compatibility + var propertyName = reader.GetString(); + + reader.Read(); + Utf8JsonReaderHelper.SkipValue(ref reader); + + logger.LogDebug("The property {Property} is unknown and skipped", propertyName); + } + + return checksum; + } + } +} diff --git a/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectDeSerializer.cs b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectDeSerializer.cs new file mode 100644 index 000000000..e44eff335 --- /dev/null +++ b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectDeSerializer.cs @@ -0,0 +1,233 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.ModelInterchange +{ + using System.Collections.Generic; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.Utility; + + /// + /// Provides low-level JSON deserialization support for the + /// descriptor defined by the KerML + /// Project Archive (.kpar) specification. + /// + /// + /// The implementation is intentionally tolerant: + /// unknown properties are skipped to allow forward compatibility + /// with future versions of the interchange specification. + /// + public static class InterchangeProjectDeSerializer + { + /// + /// Deserializes a JSON object representing an + /// from the current position of + /// a . + /// + /// + /// The positioned on a + /// token that begins + /// an JSON object. + /// + /// + /// The used to setup logging + /// + /// + /// A instance of . + /// + /// + /// Thrown when the JSON structure does not conform to the expected + /// object-based representation of an interchange project. + /// + public static InterchangeProject DeSerialize(ref Utf8JsonReader reader, ILoggerFactory loggerFactory = null) + { + var logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger("InterchangeProjectDeSerializer"); + + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.StartObject); + + var project = new InterchangeProject(); + var usage = new List(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + if (reader.ValueTextEquals("name"u8)) + { + reader.Read(); + project.Name = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("description"u8)) + { + reader.Read(); + project.Description = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("version"u8)) + { + reader.Read(); + project.Version = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("license"u8)) + { + reader.Read(); + project.License = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("maintainer"u8)) + { + reader.Read(); + project.Maintainer.Clear(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (s is not null) + { + project.Maintainer.Add(s); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw new JsonException("Expected string in maintainer array."); + } + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + continue; + } + + if (reader.ValueTextEquals("website"u8)) + { + reader.Read(); + project.Website = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("topic"u8)) + { + reader.Read(); + project.Topic.Clear(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.TokenType == JsonTokenType.String) + { + var s = reader.GetString(); + if (s is not null) + { + project.Topic.Add(s); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + throw new JsonException("Expected string in topic array."); + } + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + continue; + } + + if (reader.ValueTextEquals("usage"u8)) + { + reader.Read(); + usage.Clear(); + + if (reader.TokenType == JsonTokenType.StartArray) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndArray) + { + break; + } + + if (reader.TokenType == JsonTokenType.Null) + { + continue; + } + + var u = InterchangeProjectUsageDeSerializer.DeSerialize(ref reader, loggerFactory); + usage.Add(u); + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + project.Usage = usage; + continue; + } + + reader.Read(); + Utf8JsonReaderHelper.SkipValue(ref reader); + + logger.LogDebug("The property {Property} is unknown and skipped", reader.GetString()); + } + + return project; + } + } +} diff --git a/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectMetadataDeSerializer.cs b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectMetadataDeSerializer.cs new file mode 100644 index 000000000..4f9c89ebd --- /dev/null +++ b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectMetadataDeSerializer.cs @@ -0,0 +1,257 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.ModelInterchange +{ + using System; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.Utility; + + /// + /// Provides low-level JSON deserialization support for the + /// descriptor stored in .meta.json, + /// as defined by the KerML Project Archive (.kpar) specification. + /// + /// + /// This deserializer operates directly on a forward-only + /// and does not rely on JSON annotations, reflection, or intermediate DOM representations. + /// + /// + /// The implementation is intentionally tolerant: unknown properties are skipped to allow + /// forward compatibility with future versions of the interchange specification. + /// + /// + public static class InterchangeProjectMetadataDeSerializer + { + /// + /// Deserializes a JSON object representing an + /// instance from the current position of a . + /// + /// + /// The positioned on a + /// token that begins an JSON object. + /// + /// + /// Optional used to create a logger for diagnostics. + /// + /// + /// A populated instance. + /// + /// + /// Thrown when the JSON structure does not conform to the expected object-based + /// representation of interchange project metadata. + /// + /// + /// The following properties are recognized when present: + /// + /// + /// + /// index — mandatory JSON object mapping names to archive-relative model paths. + /// + /// + /// + /// + /// created — mandatory ISO 8601 date-time string. + /// + /// + /// + /// + /// metamodel — optional IRI/URI identifying the metamodel (language). + /// + /// + /// + /// + /// includesDerived — optional boolean indicating whether derived property values are included. + /// + /// + /// + /// + /// includesImplied — optional boolean indicating whether implied relationships are included. + /// + /// + /// + /// + /// All other properties are ignored. + /// + public static InterchangeProjectMetadata DeSerialize(ref Utf8JsonReader reader, ILoggerFactory loggerFactory = null) + { + var logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger(nameof(InterchangeProjectMetadataDeSerializer)); + + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.StartObject); + + var metadata = new InterchangeProjectMetadata(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + if (reader.ValueTextEquals("index"u8)) + { + reader.Read(); + metadata.Index.Clear(); + + if (reader.TokenType == JsonTokenType.StartObject) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in index object."); + } + + var key = reader.GetString(); + + reader.Read(); + var value = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + + if (key is not null && value is not null) + { + metadata.Index[key] = value; + } + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + continue; + } + + if (reader.ValueTextEquals("created"u8)) + { + reader.Read(); + metadata.Created = Utf8JsonReaderHelper.ReadDateTimeIso8601(ref reader); + continue; + } + + if (reader.ValueTextEquals("metamodel"u8)) + { + reader.Read(); + metadata.Metamodel = + Utf8JsonReaderHelper.ReadUriOrNull(ref reader); + continue; + } + + if (reader.ValueTextEquals("includesDerived"u8)) + { + reader.Read(); + var value = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + if (value.HasValue) + { + metadata.IncludesDerived = value.Value; + } + + continue; + } + + if (reader.ValueTextEquals("includesImplied"u8)) + { + reader.Read(); + var value = Utf8JsonReaderHelper.ReadBoolOrNull(ref reader); + if (value.HasValue) + { + metadata.IncludesImplied = value.Value; + } + + continue; + } + + if (reader.ValueTextEquals("checksum"u8)) + { + reader.Read(); + metadata.Checksum.Clear(); + + if (reader.TokenType == JsonTokenType.StartObject) + { + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name in checksum object."); + } + + var path = reader.GetString(); + + reader.Read(); + + if (reader.TokenType == JsonTokenType.StartObject) + { + var checksum = InterchangeChecksumDeserializer.DeSerialize(ref reader, loggerFactory); + + if (path is not null) + { + metadata.Checksum[path] = checksum; + } + } + else if (reader.TokenType == JsonTokenType.Null) + { + // tolerate null checksum entry + } + else + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + } + } + else if (reader.TokenType != JsonTokenType.Null) + { + Utf8JsonReaderHelper.SkipValue(ref reader); + } + + continue; + } + + // Unknown property => skip value for forward compatibility + var propertyName = reader.GetString(); + + reader.Read(); + Utf8JsonReaderHelper.SkipValue(ref reader); + + logger.LogDebug("The property {Property} is unknown and skipped", propertyName); + } + + return metadata; + } + } +} diff --git a/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectUsageDeSerializer.cs b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectUsageDeSerializer.cs new file mode 100644 index 000000000..594a77d40 --- /dev/null +++ b/SysML2.NET.Serializer.Json/ModelInterchange/InterchangeProjectUsageDeSerializer.cs @@ -0,0 +1,109 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.ModelInterchange +{ + using System; + using System.Text.Json; + + using Microsoft.Extensions.Logging; + using Microsoft.Extensions.Logging.Abstractions; + + using SysML2.NET.ModelInterchange; + using SysML2.NET.Serializer.Json.Utility; + + /// + /// Provides low-level JSON deserialization support for the + /// descriptor as defined by the + /// KerML Project Archive (.kpar) specification. + /// + /// + /// The implementation is intentionally tolerant: + /// unknown properties are skipped to allow forward compatibility + /// with future versions of the interchange specification. + /// + public static class InterchangeProjectUsageDeSerializer + { + /// + /// Deserializes a JSON object representing an + /// from the current position + /// of a . + /// + /// + /// The positioned on a + /// token that begins an + /// JSON object. + /// + /// + /// The used to setup logging + /// + /// + /// A instance of . + /// + /// + /// Thrown when the JSON structure does not conform to the expected + /// object-based representation of an interchange project. + /// + public static InterchangeProjectUsage DeSerialize(ref Utf8JsonReader reader, ILoggerFactory loggerFactory = null) + { + var logger = loggerFactory == null ? NullLogger.Instance : loggerFactory.CreateLogger("InterchangeProjectUsageDeSerializer"); + + Utf8JsonReaderHelper.Expect(ref reader, JsonTokenType.StartObject); + + var usage = new InterchangeProjectUsage(); + + while (reader.Read()) + { + if (reader.TokenType == JsonTokenType.EndObject) + { + break; + } + + if (reader.TokenType != JsonTokenType.PropertyName) + { + throw new JsonException("Expected property name."); + } + + if (reader.ValueTextEquals("resource"u8)) + { + reader.Read(); + usage.Resource = + Utf8JsonReaderHelper.ReadUriOrNull(ref reader) + ?? new Uri("about:blank", UriKind.RelativeOrAbsolute); + continue; + } + + if (reader.ValueTextEquals("versionConstraint"u8)) + { + reader.Read(); + usage.VersionConstraint = Utf8JsonReaderHelper.ReadStringOrNull(ref reader); + continue; + } + + reader.Read(); + Utf8JsonReaderHelper.SkipValue(ref reader); + + logger.LogDebug("The property {Property} is unknown and skipped", reader.GetString()); + } + + return usage; + } + } +} diff --git a/SysML2.NET.Serializer.Json/Utility/Utf8JsonReaderHelper.cs b/SysML2.NET.Serializer.Json/Utility/Utf8JsonReaderHelper.cs new file mode 100644 index 000000000..096b9fdc1 --- /dev/null +++ b/SysML2.NET.Serializer.Json/Utility/Utf8JsonReaderHelper.cs @@ -0,0 +1,171 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.Serializer.Json.Utility +{ + using System; + using System.Globalization; + using System.Text.Json; + + /// + /// Provides low-level, allocation-minimizing helper methods for working directly + /// with in streaming JSON deserializers. + /// + public static class Utf8JsonReaderHelper + { + /// + /// Ensures that the current token of the matches + /// the expected . + /// + /// + /// The positioned on the token to validate. + /// + /// + /// The expected . + /// + /// + /// Thrown when the current token does not match the expected token type. + /// + /// + /// This method is typically used immediately after advancing the reader + /// (for example, when entering an object or array) to fail fast on malformed JSON. + /// + public static void Expect(ref Utf8JsonReader reader, JsonTokenType tokenType) + { + if (reader.TokenType != tokenType) + { + throw new JsonException($"Expected {tokenType}, got {reader.TokenType}."); + } + } + + /// + /// Reads the current JSON value as a string or . + /// + /// + /// The positioned on the value token. + /// + /// + /// The string value if the token is , + /// or if the token is . + /// + /// + /// Thrown when the token is neither a string nor . + /// + /// + /// This helper avoids repeated token checks in generated deserializers + /// and enforces a strict string | null contract. + /// + public static string ReadStringOrNull(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) return null; + + if (reader.TokenType != JsonTokenType.String) throw new JsonException("Expected string or null."); + + return reader.GetString(); + } + + /// + /// Reads the current JSON value as a nullable boolean. + /// + /// + /// The positioned on the value token. + /// + /// + /// or when the token represents + /// a JSON boolean, or when the token is . + /// + /// + /// Thrown when the token is neither a boolean nor . + /// + /// + /// Intended for optional boolean properties where absence is semantically + /// different from an explicit false. + /// + public static bool? ReadBoolOrNull(ref Utf8JsonReader reader) + { + if (reader.TokenType == JsonTokenType.Null) return null; + + if (reader.TokenType == JsonTokenType.True) return true; + + if (reader.TokenType == JsonTokenType.False) return false; + + throw new JsonException("Expected bool or null."); + } + + /// + /// Reads the current JSON value as an ISO 8601 date-time string and parses it + /// into a using round-trip semantics. + /// + /// + /// The positioned on the value token. + /// + /// + /// A parsed using . + /// + /// + /// Thrown when the value is , empty, or not a valid ISO 8601 date-time string. + /// + public static DateTime ReadDateTimeIso8601(ref Utf8JsonReader reader) + { + var s = ReadStringOrNull(ref reader); + + if (string.IsNullOrWhiteSpace(s)) + { + throw new JsonException("Expected ISO 8601 date-time string."); + } + + return DateTime.Parse(s, CultureInfo.InvariantCulture, DateTimeStyles.RoundtripKind); + } + + /// + /// Reads the current JSON value as a or . + /// + /// + /// The positioned on the value token. + /// + /// + /// A created from the string value, or + /// if the token is or an empty string. + /// + /// + /// Thrown when the string value cannot be parsed as a URI. + /// + public static Uri ReadUriOrNull(ref Utf8JsonReader reader) + { + var s = ReadStringOrNull(ref reader); + + if (string.IsNullOrWhiteSpace(s)) return null; + + return new Uri(s, UriKind.RelativeOrAbsolute); + } + + /// + /// Skips the current JSON value, including any nested objects or arrays. + /// + /// + /// The positioned on the value token to skip. + /// + /// + /// This method is used to safely ignore unknown or unsupported properties + /// while remaining forward-compatible with newer schema versions. + /// + public static void SkipValue(ref Utf8JsonReader reader) => reader.Skip(); + } +} diff --git a/SysML2.NET.sln b/SysML2.NET.sln index d1814dac6..f97d22fe0 100644 --- a/SysML2.NET.sln +++ b/SysML2.NET.sln @@ -63,6 +63,10 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysML2.NET.Serializer.Xmi", EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysML2.NET.Serializer.Xmi.Tests", "SysML2.NET.Serializer.Xmi.Tests\SysML2.NET.Serializer.Xmi.Tests.csproj", "{0E02F812-6B73-4CD9-A96F-54D7389873CF}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysML2.NET.Kpar", "SysML2.NET.Kpar\SysML2.NET.Kpar.csproj", "{D199B169-CEF0-4D6E-9CC5-83AB86EB809B}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "SysML2.NET.Kpar.Tests", "SysML2.NET.Kpar.Tests\SysML2.NET.Kpar.Tests.csproj", "{ACA8A0A2-EF4E-4F22-9164-07F6550F81F0}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -137,6 +141,14 @@ Global {0E02F812-6B73-4CD9-A96F-54D7389873CF}.Debug|Any CPU.Build.0 = Debug|Any CPU {0E02F812-6B73-4CD9-A96F-54D7389873CF}.Release|Any CPU.ActiveCfg = Release|Any CPU {0E02F812-6B73-4CD9-A96F-54D7389873CF}.Release|Any CPU.Build.0 = Release|Any CPU + {D199B169-CEF0-4D6E-9CC5-83AB86EB809B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D199B169-CEF0-4D6E-9CC5-83AB86EB809B}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D199B169-CEF0-4D6E-9CC5-83AB86EB809B}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D199B169-CEF0-4D6E-9CC5-83AB86EB809B}.Release|Any CPU.Build.0 = Release|Any CPU + {ACA8A0A2-EF4E-4F22-9164-07F6550F81F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {ACA8A0A2-EF4E-4F22-9164-07F6550F81F0}.Debug|Any CPU.Build.0 = Debug|Any CPU + {ACA8A0A2-EF4E-4F22-9164-07F6550F81F0}.Release|Any CPU.ActiveCfg = Release|Any CPU + {ACA8A0A2-EF4E-4F22-9164-07F6550F81F0}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE diff --git a/SysML2.NET/ModelInterchange/Archive.cs b/SysML2.NET/ModelInterchange/Archive.cs new file mode 100644 index 000000000..f1845e05e --- /dev/null +++ b/SysML2.NET/ModelInterchange/Archive.cs @@ -0,0 +1,59 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System; + using System.Collections.Generic; + + /// + /// Represents the logical contents of a .kpar archive. + /// + public class Archive + { + /// + /// Gets or sets the interchange project descriptor (serialized to .project.json). + /// + public InterchangeProject Project { get; set; } + + /// + /// Gets or sets the interchange metadata (serialized to .meta.json). + /// + public InterchangeProjectMetadata Metadata { get; set; } + + /// + /// Gets the Path of the kpar that has been read + /// + public string Path { get; set; } + + /// + /// Gets the model interchange files contained in the archive. + /// + /// + /// Each entry uses an archive-relative path (forward slashes). + /// + public IReadOnlyList Models { get; set; } = Array.Empty(); + + /// + /// Gets the checksum validation mismatches detected while reading a .kpar archive. + /// + public IReadOnlyList ChecksumMismatches { get; set; } = Array.Empty(); + } +} diff --git a/SysML2.NET/ModelInterchange/ChecksumKind.cs b/SysML2.NET/ModelInterchange/ChecksumKind.cs new file mode 100644 index 000000000..1bdada140 --- /dev/null +++ b/SysML2.NET/ModelInterchange/ChecksumKind.cs @@ -0,0 +1,108 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2025 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + /// + /// Identifies the algorithm used to compute a checksum value in an interchange project. + /// + public enum ChecksumKind + { + /// + /// SHA-1 hash algorithm. + /// + SHA1, + + /// + /// SHA-224 hash algorithm. + /// + SHA224, + + /// + /// SHA-256 hash algorithm. + /// + SHA256, + + /// + /// SHA-384 hash algorithm. + /// + SHA384, + + /// + /// SHA3-256 hash algorithm. + /// + SHA3256, + + /// + /// SHA3-384 hash algorithm. + /// + SHA3384, + + /// + /// SHA3-512 hash algorithm. + /// + SHA3512, + + /// + /// BLAKE2b-256 hash algorithm. + /// + BLAKE2b256, + + /// + /// BLAKE2b-384 hash algorithm. + /// + BLAKE2b384, + + /// + /// BLAKE2b-512 hash algorithm. + /// + BLAKE2b512, + + /// + /// BLAKE3 hash algorithm. + /// + BLAKE3, + + /// + /// MD2 hash algorithm. + /// + MD2, + + /// + /// MD4 hash algorithm. + /// + MD4, + + /// + /// MD5 hash algorithm. + /// + MD5, + + /// + /// MD6 hash algorithm. + /// + MD6, + + /// + /// ADLER32 checksum algorithm. + /// + ADLER32 + } +} diff --git a/SysML2.NET/ModelInterchange/ChecksumMismatch.cs b/SysML2.NET/ModelInterchange/ChecksumMismatch.cs new file mode 100644 index 000000000..c3ef8e91d --- /dev/null +++ b/SysML2.NET/ModelInterchange/ChecksumMismatch.cs @@ -0,0 +1,52 @@ +namespace SysML2.NET.ModelInterchange +{ + /// + /// Represents a checksum mismatch detected during validation of a .kpar archive. + /// + public sealed class ChecksumMismatch + { + /// + /// Gets the metadata index key associated with the model entry. + /// + /// + /// This corresponds to a key in the index object of .meta.json + /// (for example "Base"). + /// + public string IndexKey { get; set; } + + /// + /// Gets the archive-relative path of the model file. + /// + /// + /// This is the path within the ZIP container (for example "Base.kerml"), + /// normalized to use forward slashes. + /// + public string Path { get; set; } + + /// + /// Gets the checksum algorithm declared in .meta.json. + /// + /// + /// Typical values include SHA256, SHA3-256, or other + /// algorithms supported by the KerML specification. + /// + public ChecksumKind Algorithm { get; set; } + + /// + /// Gets the checksum value declared in .meta.json. + /// + /// + /// This value represents the expected integrity hash for the model file. + /// + public string Expected { get; set; } + + /// + /// Gets the checksum value computed from the actual file contents in the archive. + /// + /// + /// If this value differs from , the archive integrity + /// has been compromised or the archive contents have changed since creation. + /// + public string Actual { get; set; } + } +} diff --git a/SysML2.NET/ModelInterchange/InterchangeChecksum.cs b/SysML2.NET/ModelInterchange/InterchangeChecksum.cs new file mode 100644 index 000000000..06bb66839 --- /dev/null +++ b/SysML2.NET/ModelInterchange/InterchangeChecksum.cs @@ -0,0 +1,44 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + /// + /// Represents a checksum entry for a model interchange file. + /// + public sealed class InterchangeChecksum + { + /// + /// Gets or sets the checksum value computed for the file. + /// + /// + /// Mandatory. Hexadecimal-encoded string. + /// + public string Value { get; set; } = string.Empty; + + /// + /// Gets or sets the algorithm used to compute the checksum. + /// + /// + /// Mandatory. Must match one of the supported algorithms defined by . + /// + public ChecksumKind Algorithm { get; set; } + } +} diff --git a/SysML2.NET/ModelInterchange/InterchangeProject.cs b/SysML2.NET/ModelInterchange/InterchangeProject.cs new file mode 100644 index 000000000..1fdb01702 --- /dev/null +++ b/SysML2.NET/ModelInterchange/InterchangeProject.cs @@ -0,0 +1,65 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System.Collections.Generic; + + /// + /// KerML interchange project information stored in .project.json. + /// + /// + /// The .project.json file contains the InterchangeProject information (Figure 42), serialized as a single JSON object + /// according to the normative schema referenced by the specification. + /// + public class InterchangeProject : ProjectBase + { + /// + /// The version of the project being interchanged. + /// + public string Version { get; set; } + + /// + /// The license by which project content may be used. + /// + public string License { get; set; } + + /// + /// A list of names of maintainers of the project. + /// + public List Maintainer { get; set; } = []; + + /// + /// An IRI for a Web site with further information on the project. + /// + public string Website { get; set; } + + /// + /// A list of topics relevant to the project. + /// + public List Topic { get; set; } = []; + + /// + /// A list of project usage entries, one for each project used by the project + /// being interchanged, with properties as given below. + /// + public List Usage { get; set; } = []; + } +} diff --git a/SysML2.NET/ModelInterchange/InterchangeProjectMetadata.cs b/SysML2.NET/ModelInterchange/InterchangeProjectMetadata.cs new file mode 100644 index 000000000..f9a90c116 --- /dev/null +++ b/SysML2.NET/ModelInterchange/InterchangeProjectMetadata.cs @@ -0,0 +1,86 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System; + using System.Collections.Generic; + + /// + /// KerML interchange project metadata stored in .meta.json. + /// + /// + /// The .meta.json file contains additional metadata for the project interchange archive, serialized as a single JSON object. + /// Table 13 in Section 10.3 lists the fields. + /// + public class InterchangeProjectMetadata + { + /// + /// Gets or sets the index of the global scope of the project. + /// + /// + /// Mandatory. A JSON object with a key for each name, mapping to the path of the model interchange file containing the + /// root namespace for the named element. + /// + public Dictionary Index { get; set; } = new(StringComparer.Ordinal); + + /// + /// Gets or sets the creation timestamp of the project interchange file. + /// + /// + /// Mandatory. Stored as an ISO 8601 date-time string in the serialized form. + /// + public DateTimeOffset Created { get; set; } + + /// + /// Gets or sets an optional IRI identifying the metamodel (language) of the models in the project interchange file. + /// + /// + /// Optional. For OMG-standardized languages, this is the version-specific URI specified by OMG; for KerML it has the form + /// https://www.omg.org/spec/KerML/yyyymmxx. If not given and the archive uses .kpar, the default is KerML. + /// + public Uri Metamodel { get; set; } + + /// + /// Gets or sets whether derived property values are included in the project’s XMI/JSON model interchange files. + /// + /// + /// Optional. If true, all derived properties must be included; if false, none may be included; if omitted, inclusion may vary. + /// + public bool IncludesDerived { get; set; } + + /// + /// Gets or sets whether implied relationships are included in the project’s XMI/JSON model interchange files. + /// + /// + /// Optional. If true, implied relationships must be included; if false, none may be included; if omitted, inclusion may vary. + /// + public bool IncludesImplied { get; set; } + + /// + /// Gets or sets the checksum dictionary for model interchange files. + /// + /// + /// Key = relative file path. + /// Value = checksum information for that file. + /// + public Dictionary Checksum { get; set; } = new(StringComparer.Ordinal); + } +} diff --git a/SysML2.NET/ModelInterchange/InterchangeProjectUsage.cs b/SysML2.NET/ModelInterchange/InterchangeProjectUsage.cs new file mode 100644 index 000000000..67744d72c --- /dev/null +++ b/SysML2.NET/ModelInterchange/InterchangeProjectUsage.cs @@ -0,0 +1,46 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System; + + /// + /// Describes a used project entry in an usage list. + /// + public class InterchangeProjectUsage : ProjectUsageBase + { + /// + /// Gets or sets the IRI identifying the project being used. + /// + /// + /// Mandatory (within a usage). + /// + /// If the IRI is dereferenceable, it SHOULD resolve to a project interchange file + /// for the used project. + /// + public Uri Resource { get; set; } = new Uri("about:blank"); + + /// + /// A constraint on the allowable versions of a used project. + /// + public string VersionConstraint { get; set; } + } +} diff --git a/SysML2.NET/ModelInterchange/ModelEntry.cs b/SysML2.NET/ModelInterchange/ModelEntry.cs new file mode 100644 index 000000000..2b178b80a --- /dev/null +++ b/SysML2.NET/ModelInterchange/ModelEntry.cs @@ -0,0 +1,51 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + using System; + using System.IO; + using System.Threading; + using System.Threading.Tasks; + + /// + /// A model file entry inside a .kpar archive. + /// + public sealed class ModelEntry + { + /// + /// Gets the archive-relative path of the entry (e.g., structure/Body.json). + /// + public string Path { get; set; } + + /// + /// Gets an optional content type hint (e.g., application/json, application/xml, text/plain). + /// + public string ContentType { get; set; } + + /// + /// Gets a factory that opens a readable stream for the entry content. + /// + /// + /// Using a factory avoids lifetime issues when the package object outlives the underlying ZIP stream. + /// + public Func> OpenReadAsync { get; set; } + } +} diff --git a/SysML2.NET/ModelInterchange/ProjectBase.cs b/SysML2.NET/ModelInterchange/ProjectBase.cs new file mode 100644 index 000000000..687cf142d Binary files /dev/null and b/SysML2.NET/ModelInterchange/ProjectBase.cs differ diff --git a/SysML2.NET/ModelInterchange/ProjectUsageBase.cs b/SysML2.NET/ModelInterchange/ProjectUsageBase.cs new file mode 100644 index 000000000..0250e75b8 --- /dev/null +++ b/SysML2.NET/ModelInterchange/ProjectUsageBase.cs @@ -0,0 +1,42 @@ +// ------------------------------------------------------------------------------------------------- +// +// +// Copyright 2022-2026 Starion Group S.A. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// +// ------------------------------------------------------------------------------------------------ + +namespace SysML2.NET.ModelInterchange +{ + /// + /// Base type for representing a “project usage” entry (a used project) in an interchange project. + /// + /// + /// Each usage entry identifies a used project via an IRI and may constrain which versions of that project are acceptable. + /// + public abstract class ProjectUsageBase + { + /// + /// TBD from Kerml Spec + /// + public ProjectBase UsingProject { get; set; } + + /// + /// TBD from Kerml Spec + /// + public ProjectBase UsedProject { get; set; } + } +} +