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) | 
[SysML2.NET.Serializer.Json](https://www.nuget.org/packages/SysML2.NET.Serializer.Json) | 
[SysML2.NET.Serializer.MessagePack](https://www.nuget.org/packages/SysML2.NET.Serializer.MessagePack) | 
+[SysML2.NET.Kpar](https://www.nuget.org/packages/SysML2.NET.Kpar) | 
[SysML2.NET.REST](https://www.nuget.org/packages/SysML2.NET.REST) | 
[SysML2.NET.DAL](https://www.nuget.org/packages/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; }
+ }
+}
+