From 8835b23ed3fcb172c3f4d1d068e6142d53223137 Mon Sep 17 00:00:00 2001 From: Pavel Gross Date: Thu, 27 Feb 2025 17:35:19 +0300 Subject: [PATCH] [#34] Client: Add rules deserialization Signed-off-by: Pavel Gross --- src/FrostFS.SDK.Client/ApeRules/Actions.cs | 2 +- .../ApeRules/FrostFsRule.cs | 2 +- src/FrostFS.SDK.Client/ApeRules/Resources.cs | 12 +- .../ApeRules/RuleSerializer.cs | 243 +++++++++++++++++- src/FrostFS.SDK.Client/FrostFSClient.cs | 4 +- .../Interfaces/IFrostFSClient.cs | 4 +- .../Models/Session/FrostFsSessionToken.cs | 1 - .../Parameters/PrmApeChainAdd.cs | 4 +- .../Parameters/PrmApeChainList.cs | 4 +- .../Parameters/PrmApeChainRemove.cs | 5 +- src/FrostFS.SDK.Client/Pool/Pool.cs | 4 +- .../Services/ApeManagerServiceProvider.cs | 7 +- .../Smoke/Client/MiscTests/InterceptorTest.cs | 2 + .../Smoke/Client/ObjectTests/ObjectTests.cs | 5 + .../MultithreadSmokeClientTests.cs | 2 - src/FrostFS.SDK.Tests/Smoke/SmokeTestsBase.cs | 4 +- src/FrostFS.SDK.Tests/Unit/ApeTests.cs | 167 ++++++++++++ src/FrostFS.SDK.Tests/Unit/ObjectTest.cs | 6 +- 18 files changed, 426 insertions(+), 52 deletions(-) create mode 100644 src/FrostFS.SDK.Tests/Unit/ApeTests.cs diff --git a/src/FrostFS.SDK.Client/ApeRules/Actions.cs b/src/FrostFS.SDK.Client/ApeRules/Actions.cs index 28600b8..71888b9 100644 --- a/src/FrostFS.SDK.Client/ApeRules/Actions.cs +++ b/src/FrostFS.SDK.Client/ApeRules/Actions.cs @@ -6,7 +6,7 @@ public struct Actions(bool inverted, string[] names) : System.IEquatable +public struct Resources(bool inverted, string[] names) : System.IEquatable { public bool Inverted { get; set; } = inverted; @@ -8,10 +8,10 @@ public struct Resource(bool inverted, string[] names) : System.IEquatable(byte[] buf, int offset, T[] slice, Func marshalT) + private static int SliceMarshal(byte[] buf, int offset, T[] slice, Func marshalT) { if (slice == null) { @@ -269,7 +305,7 @@ internal static class RuleSerializer return SliceMarshal(buf, offset, rule.Conditions!, MarshalCondition); } - private static int MarshalResources(byte[] buf, int offset, Resource resources) + private static int MarshalResources(byte[] buf, int offset, Resources resources) { offset = BoolMarshal(buf, offset, resources.Inverted); @@ -283,4 +319,187 @@ internal static class RuleSerializer throw new FrostFsException("actual data size differs from expected"); } } + + private static (int, bool) BoolUnmarshal(byte[] buf, int offset) + { + (offset, byte val) = UInt8Unmarshal(buf, offset); + return (offset, val == ByteTrue); + } + + private static (int, string) StringUnmarshal(byte[] buf, int offset) + { + (offset, long size) = Int64Unmarshal(buf, offset); + + if (size == 0) + { + return (offset, string.Empty); + } + + if (size > MaxSliceLen) + { + throw new FrostFsException($"string is too long: '{size}'"); + } + if (size < 0) + { + throw new FrostFsException($"invalid string size: '{size}'"); + } + + if (buf.Length - offset < size) + { + throw new FrostFsException($"not enough bytes left to string value"); + } + + return (offset + (int)size, System.Text.Encoding.UTF8.GetString(buf, offset, (int)size)); + } + + private static (int, Actions) UnmarshalActions(byte[] buf, int offset) + { + Actions action = new(); + (offset, action.Inverted) = BoolUnmarshal(buf, offset); + + (offset, action.Names) = SliceUnmarshal(buf, offset, StringUnmarshal); + + return (offset, action); + } + + private static (int, Resources) UnmarshalResources(byte[] buf, int offset) + { + Resources res = new(); + + (offset, res.Inverted) = BoolUnmarshal(buf, offset); + (offset, res.Names) = SliceUnmarshal(buf, offset, StringUnmarshal); + + return (offset, res); + } + + private static (int, Condition) UnmarshalCondition(byte[] buf, int offset) + { + Condition cond = new(); + (offset, var op) = UInt8Unmarshal(buf, offset); + + cond.Op = (ConditionType)op; + + (offset, var kind) = UInt8Unmarshal(buf, offset); + + cond.Kind = (ConditionKindType)kind; + + (offset, cond.Key) = StringUnmarshal(buf, offset); + + (offset, cond.Value) = StringUnmarshal(buf, offset); + + return (offset, cond); + } + + private static (int, FrostFsRule) UnmarshalRule(byte[] buf, int offset) + { + FrostFsRule rule = new(); + + (offset, byte statusV) = UInt8Unmarshal(buf, offset); + rule.Status = (RuleStatus)statusV; + + (offset, rule.Actions) = UnmarshalActions(buf, offset); + + (offset, rule.Resources) = UnmarshalResources(buf, offset); + + (offset, rule.Any) = BoolUnmarshal(buf, offset); + + (offset, rule.Conditions) = SliceUnmarshal(buf, offset, UnmarshalCondition); + + return (offset, rule); + } + + private static (int, byte) UInt8Unmarshal(byte[] buf, int offset) + { + if (buf.Length - offset < 1) + { + throw new FrostFsException($"not enough bytes left to read a value of type 'byte' from offset {offset}"); + } + + return (offset + 1, buf[offset]); + } + + private static (int, long) Int64Unmarshal(byte[] buf, int offset) + { + if (buf.Length - offset < sizeof(long)) + { + throw new FrostFsException($"not enough bytes left to read a value of type 'long' from offset {offset}"); + } + + return Varint(buf, offset); + } + + private static (int, T[]) SliceUnmarshal(byte[] buf, int offset, Func unmarshalT) + { + var (newOffset, size) = Varint(buf, offset); + + if (size == NullSlice) + { + return (newOffset, []); + } + + if (size > MaxSliceLen) + { + throw new FrostFsException($"slice size is too big: '{size}'"); + } + + if (size < 0) + { + throw new FrostFsException($"invalid slice size: '{size}'"); + } + + var result = new T[size]; + for (int i = 0; i < result.Length; i++) + { + (newOffset, result[i]) = unmarshalT(buf, newOffset); + } + + return (newOffset, result); + } + + private static void VerifyUnmarshal(byte[] buf, int offset) + { + if (buf.Length != offset) + { + throw new FrostFsException("unmarshalled bytes left"); + } + } + + private static int MaxVarIntLen64 = 10; + + public static (int, long) Varint(byte[] buf, int offset) + { + var (ux, n) = Uvarint(buf, offset); // ok to continue in presence of error + long x = (long)ux >> 1; + if ((ux & 1) != 0) + { + x = ~x; + } + return (n, x); + } + + public static (ulong, int) Uvarint(byte[] buf, int offset) + { + ulong x = 0; + int s = 0; + + for (int i = offset; i < buf.Length; i++) + { + byte b = buf[i]; + if (i == MaxVarIntLen64) + { + return (0, -(i + 1)); // overflow + } + if (b < 0x80) + { + if (i == MaxVarIntLen64 - 1 && b > 1) + { + return (0, -(i + 1)); // overflow + } + return (x | ((ulong)b << s), i + 1); + } + x |= (ulong)(b & 0x7f) << s; + s += 7; + } + return (0, 0); + } } diff --git a/src/FrostFS.SDK.Client/FrostFSClient.cs b/src/FrostFS.SDK.Client/FrostFSClient.cs index 83f917e..f72265c 100644 --- a/src/FrostFS.SDK.Client/FrostFSClient.cs +++ b/src/FrostFS.SDK.Client/FrostFSClient.cs @@ -2,8 +2,6 @@ using System.Collections.Generic; using System.Threading.Tasks; -using Frostfs.V2.Ape; - using FrostFS.SDK.Client.Interfaces; using FrostFS.SDK.Client.Services; using FrostFS.SDK.Cryptography; @@ -195,7 +193,7 @@ public class FrostFSClient : IFrostFSClient return GetApeManagerService().RemoveChainAsync(args, ctx); } - public Task ListChainAsync(PrmApeChainList args, CallContext ctx) + public Task ListChainAsync(PrmApeChainList args, CallContext ctx) { return GetApeManagerService().ListChainAsync(args, ctx); } diff --git a/src/FrostFS.SDK.Client/Interfaces/IFrostFSClient.cs b/src/FrostFS.SDK.Client/Interfaces/IFrostFSClient.cs index 594cc63..d546156 100644 --- a/src/FrostFS.SDK.Client/Interfaces/IFrostFSClient.cs +++ b/src/FrostFS.SDK.Client/Interfaces/IFrostFSClient.cs @@ -2,8 +2,6 @@ using System; using System.Collections.Generic; using System.Threading.Tasks; -using Frostfs.V2.Ape; - namespace FrostFS.SDK.Client.Interfaces; public interface IFrostFSClient @@ -25,7 +23,7 @@ public interface IFrostFSClient Task RemoveChainAsync(PrmApeChainRemove args, CallContext ctx); - Task ListChainAsync(PrmApeChainList args, CallContext ctx); + Task ListChainAsync(PrmApeChainList args, CallContext ctx); #endregion #region Container diff --git a/src/FrostFS.SDK.Client/Models/Session/FrostFsSessionToken.cs b/src/FrostFS.SDK.Client/Models/Session/FrostFsSessionToken.cs index 5f675ae..708c9f6 100644 --- a/src/FrostFS.SDK.Client/Models/Session/FrostFsSessionToken.cs +++ b/src/FrostFS.SDK.Client/Models/Session/FrostFsSessionToken.cs @@ -1,5 +1,4 @@ using System; -using System.Diagnostics; using FrostFS.Refs; using FrostFS.SDK.Client; diff --git a/src/FrostFS.SDK.Client/Parameters/PrmApeChainAdd.cs b/src/FrostFS.SDK.Client/Parameters/PrmApeChainAdd.cs index 54b5171..adcc1b9 100644 --- a/src/FrostFS.SDK.Client/Parameters/PrmApeChainAdd.cs +++ b/src/FrostFS.SDK.Client/Parameters/PrmApeChainAdd.cs @@ -1,6 +1,4 @@ -using FrostFS.SDK.Client; - -namespace FrostFS.SDK.Client; +namespace FrostFS.SDK.Client; public readonly struct PrmApeChainAdd(FrostFsChainTarget target, FrostFsChain chain, string[]? xheaders = null) : System.IEquatable { diff --git a/src/FrostFS.SDK.Client/Parameters/PrmApeChainList.cs b/src/FrostFS.SDK.Client/Parameters/PrmApeChainList.cs index 57c90bd..a946840 100644 --- a/src/FrostFS.SDK.Client/Parameters/PrmApeChainList.cs +++ b/src/FrostFS.SDK.Client/Parameters/PrmApeChainList.cs @@ -1,6 +1,4 @@ -using FrostFS.SDK.Client; - -namespace FrostFS.SDK.Client; +namespace FrostFS.SDK.Client; public readonly struct PrmApeChainList(FrostFsChainTarget target, string[]? xheaders = null) : System.IEquatable { diff --git a/src/FrostFS.SDK.Client/Parameters/PrmApeChainRemove.cs b/src/FrostFS.SDK.Client/Parameters/PrmApeChainRemove.cs index 1fc110b..720c3c4 100644 --- a/src/FrostFS.SDK.Client/Parameters/PrmApeChainRemove.cs +++ b/src/FrostFS.SDK.Client/Parameters/PrmApeChainRemove.cs @@ -1,7 +1,4 @@ -using System; -using FrostFS.SDK.Client; - -namespace FrostFS.SDK.Client; +namespace FrostFS.SDK.Client; public readonly struct PrmApeChainRemove( FrostFsChainTarget target, diff --git a/src/FrostFS.SDK.Client/Pool/Pool.cs b/src/FrostFS.SDK.Client/Pool/Pool.cs index f0b7a6e..cd09d30 100644 --- a/src/FrostFS.SDK.Client/Pool/Pool.cs +++ b/src/FrostFS.SDK.Client/Pool/Pool.cs @@ -5,8 +5,6 @@ using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; -using Frostfs.V2.Ape; - using FrostFS.Refs; using FrostFS.SDK.Client.Interfaces; using FrostFS.SDK.Client.Mappers.GRPC; @@ -550,7 +548,7 @@ public partial class Pool : IFrostFSClient await client.Client!.RemoveChainAsync(args, ctx).ConfigureAwait(false); } - public async Task ListChainAsync(PrmApeChainList args, CallContext ctx) + public async Task ListChainAsync(PrmApeChainList args, CallContext ctx) { var client = Connection(); return await client.Client!.ListChainAsync(args, ctx).ConfigureAwait(false); diff --git a/src/FrostFS.SDK.Client/Services/ApeManagerServiceProvider.cs b/src/FrostFS.SDK.Client/Services/ApeManagerServiceProvider.cs index 174d460..5331b80 100644 --- a/src/FrostFS.SDK.Client/Services/ApeManagerServiceProvider.cs +++ b/src/FrostFS.SDK.Client/Services/ApeManagerServiceProvider.cs @@ -1,7 +1,6 @@ using System; +using System.Linq; using System.Threading.Tasks; - -using Frostfs.V2.Ape; using Frostfs.V2.Apemanager; using Google.Protobuf; @@ -61,7 +60,7 @@ internal sealed class ApeManagerServiceProvider : ContextAccessor Verifier.CheckResponse(response); } - internal async Task ListChainAsync(PrmApeChainList args, CallContext ctx) + internal async Task ListChainAsync(PrmApeChainList args, CallContext ctx) { ListChainsRequest request = new() { @@ -78,6 +77,6 @@ internal sealed class ApeManagerServiceProvider : ContextAccessor Verifier.CheckResponse(response); - return [.. response.Body.Chains]; + return [.. response.Body.Chains.Select(c => RuleSerializer.Deserialize([.. c.Raw]))]; } } \ No newline at end of file diff --git a/src/FrostFS.SDK.Tests/Smoke/Client/MiscTests/InterceptorTest.cs b/src/FrostFS.SDK.Tests/Smoke/Client/MiscTests/InterceptorTest.cs index c3df8e5..664d522 100644 --- a/src/FrostFS.SDK.Tests/Smoke/Client/MiscTests/InterceptorTest.cs +++ b/src/FrostFS.SDK.Tests/Smoke/Client/MiscTests/InterceptorTest.cs @@ -1,8 +1,10 @@ +using System.Diagnostics.CodeAnalysis; using FrostFS.SDK.Client; using FrostFS.SDK.SmokeTests; namespace FrostFS.SDK.Tests.Smoke; +[SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Default Value is correct for tests")] public class InterceptorTests() : SmokeTestsBase { [Fact] diff --git a/src/FrostFS.SDK.Tests/Smoke/Client/ObjectTests/ObjectTests.cs b/src/FrostFS.SDK.Tests/Smoke/Client/ObjectTests/ObjectTests.cs index 98e32f7..b6ae62b 100644 --- a/src/FrostFS.SDK.Tests/Smoke/Client/ObjectTests/ObjectTests.cs +++ b/src/FrostFS.SDK.Tests/Smoke/Client/ObjectTests/ObjectTests.cs @@ -124,11 +124,16 @@ public class ObjectTests(ITestOutputHelper testOutputHelper) : SmokeTestsBase var hashes = await client.GetRangeHashAsync(rangeParam, default); + var objectRange = bytes.AsMemory().Slice(100, 64).ToArray(); + var expectedHash = SHA256.HashData(objectRange); + foreach (var h in hashes) { var x = h[..32].ToArray(); Assert.NotNull(x); Assert.True(x.Length > 0); + + // Assert.True(expectedHash.SequenceEqual(h.ToArray())); } } diff --git a/src/FrostFS.SDK.Tests/Smoke/PoolTests/Multithread/MultithreadSmokeClientTests.cs b/src/FrostFS.SDK.Tests/Smoke/PoolTests/Multithread/MultithreadSmokeClientTests.cs index 32aeb2e..7de8155 100644 --- a/src/FrostFS.SDK.Tests/Smoke/PoolTests/Multithread/MultithreadSmokeClientTests.cs +++ b/src/FrostFS.SDK.Tests/Smoke/PoolTests/Multithread/MultithreadSmokeClientTests.cs @@ -6,8 +6,6 @@ using FrostFS.SDK.Client.Interfaces; using FrostFS.SDK.Cryptography; using FrostFS.SDK.SmokeTests; -using Microsoft.Extensions.Options; - namespace FrostFS.SDK.Tests.Smoke; [SuppressMessage("Reliability", "CA2007:Consider calling ConfigureAwait on the awaited task", Justification = "Default Value is correct for tests")] diff --git a/src/FrostFS.SDK.Tests/Smoke/SmokeTestsBase.cs b/src/FrostFS.SDK.Tests/Smoke/SmokeTestsBase.cs index ac76a54..59099a9 100644 --- a/src/FrostFS.SDK.Tests/Smoke/SmokeTestsBase.cs +++ b/src/FrostFS.SDK.Tests/Smoke/SmokeTestsBase.cs @@ -1,7 +1,6 @@ using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Security.Cryptography; -using System.Security.Principal; using System.Text; using FrostFS.SDK.Client; using FrostFS.SDK.Client.Interfaces; @@ -10,7 +9,6 @@ using FrostFS.SDK.Cryptography; using Grpc.Core; using Microsoft.Extensions.Options; -using Xunit.Abstractions; namespace FrostFS.SDK.Tests.Smoke; @@ -121,7 +119,7 @@ public abstract class SmokeTestsBase { Status = RuleStatus.Allow, Actions = new Actions(inverted: false, names: ["*"]), - Resources = new Resource (inverted: false, names: [$"native:object/*"]), + Resources = new Resources (inverted: false, names: [$"native:object/*"]), Any = false, Conditions = [] } diff --git a/src/FrostFS.SDK.Tests/Unit/ApeTests.cs b/src/FrostFS.SDK.Tests/Unit/ApeTests.cs new file mode 100644 index 0000000..dea17fb --- /dev/null +++ b/src/FrostFS.SDK.Tests/Unit/ApeTests.cs @@ -0,0 +1,167 @@ +using System.Text; +using FrostFS.SDK.Client; + +namespace FrostFS.SDK.Tests.Unit; + +public class ApeTests : ContainerTestsBase +{ + [Fact] + public void ApeRule1Test() + { + var chain = new FrostFsChain + { + ID = Encoding.ASCII.GetBytes("chain-id-test"), + Rules = [ + new FrostFsRule + { + Status = RuleStatus.Allow, + Actions = new Actions(inverted: false, names: ["*"]), + Resources = new Resources (inverted: false, names: [$"native:object/*"]), + Any = false, + Conditions = [] + } + ], + MatchType = RuleMatchType.DenyPriority + }; + + var serialized = RuleSerializer.Serialize(chain); + var restoredChain = RuleSerializer.Deserialize(serialized); + + Assert.True(chain.ID.SequenceEqual(restoredChain.ID)); + + Assert.Equal(chain.MatchType, restoredChain.MatchType); + CompareRules(chain.Rules, restoredChain.Rules); + } + + [Fact] + public void ApeRule2Test() + { + var chain = new FrostFsChain + { + ID = Encoding.ASCII.GetBytes("dumptext"), + Rules = [ + new FrostFsRule + { + Status = RuleStatus.AccessDenied, + Actions = new Actions(inverted: true, names: ["put,get"]), + Resources = new Resources (inverted: true, names: [$"native:object/*,blablabla"]), + Any = true, + Conditions = [ + new () { + Key = "key", + Value = "value", + Kind = ConditionKindType.Resource, + Op = ConditionType.CondStringEquals + }, + new () { + Key = "key1", + Value = "value1", + Kind = ConditionKindType.Request, + Op = ConditionType.CondNumericGreaterThan + } + ] + } + ], + MatchType = RuleMatchType.FirstMatch + }; + + var serialized = RuleSerializer.Serialize(chain); + var restoredChain = RuleSerializer.Deserialize(serialized); + + Assert.True(chain.ID.SequenceEqual(restoredChain.ID)); + + Assert.Equal(chain.MatchType, restoredChain.MatchType); + CompareRules(chain.Rules, restoredChain.Rules); + } + + [Fact] + public void NegativeDeserialize1Test() + { + try + { + _ = RuleSerializer.Deserialize(null); + Assert.Fail("Error is expected"); + } + catch (ArgumentNullException) + { + } + } + + [Fact] + public void NegativeDeserialize2Test() + { + try + { + _ = RuleSerializer.Deserialize([]); + Assert.Fail("Error is expected"); + } + catch (FrostFsException) + { + } + } + + [Fact] + public void NegativeDeserialize3Test() + { + try + { + _ = RuleSerializer.Deserialize([1, 2, 3]); + Assert.Fail("Error is expected"); + } + catch (FrostFsException) + { + } + } + + [Fact] + public void NegativeDeserialize4Test() + { + try + { + //"\x00\x00:aws:iam::namespace:group/so\x82\x82\x82\x82\x82\x82u\x82" + _ = RuleSerializer.Deserialize([0x00, 0x00, 0x3A, 0x77, 0x73, 0x3A, 0x69, 0x61, 0x6D, 0x3A, 0x3A, 0x6E, 0x61, 0x6D, 0x65, 0x73, 0x70, 0x61, 0x63, 0x65, 0x3A, 0x67, 0x72, 0x6F, 0x75, 0x70, 0x2F, 0x73, 0x6F, 0x82, 0x82, 0x82, 0x82, 0x82, 0x82, 0x75, 0x82]); + Assert.Fail("Error is expected"); + } + catch (FrostFsException) + { + } + } + + private static void CompareRules(FrostFsRule[] rules1, FrostFsRule[] rules2) + { + Assert.NotNull(rules1); + Assert.NotNull(rules2); + + Assert.Equal(rules1.Length, rules2.Length); + + for (int ri = 0; ri < rules1.Length; ri++) + { + var rule1 = rules1[ri]; + var rule2 = rules2[ri]; + + Assert.Equal(rule1.Status, rule2.Status); + Assert.Equal(rule1.Any, rule2.Any); + + Assert.Equal(rule1.Actions, rule2.Actions); + + Assert.Equal(rule1.Resources, rule2.Resources); + + bool cond1Empty = rule1.Conditions == null || rule1.Conditions.Length == 0; + bool cond2Empty = rule2.Conditions == null || rule2.Conditions.Length == 0; + + if (cond1Empty && cond2Empty) + { + return; + } + + Assert.Equal(cond1Empty, cond2Empty); + + Assert.Equal(rule1.Conditions!.Length, rule2.Conditions!.Length); + + for (int i = 0; i < rule1.Conditions.Length; i++) + { + Assert.Equal(rule1.Conditions[i], rule2.Conditions[i]); + } + } + } +} \ No newline at end of file diff --git a/src/FrostFS.SDK.Tests/Unit/ObjectTest.cs b/src/FrostFS.SDK.Tests/Unit/ObjectTest.cs index 445dd80..564627d 100644 --- a/src/FrostFS.SDK.Tests/Unit/ObjectTest.cs +++ b/src/FrostFS.SDK.Tests/Unit/ObjectTest.cs @@ -111,12 +111,12 @@ public class ObjectTest : ObjectTestsBase Assert.NotNull(header1.Split.SplitId); Assert.Null(header1.Split.Previous); - Assert.Equal(bytes[..blockSize], payload1); + Assert.Equal(SHA256.HashData(bytes.AsMemory().Slice(0, blockSize).ToArray()), SHA256.HashData(payload1)); Assert.True(header1.Attributes.Count == 0); Assert.Equal(header1.Split.SplitId, header2.Split.SplitId); Assert.Equal(objIds.ElementAt(0), header2.Split.Previous.Value); - Assert.Equal(bytes[blockSize..(blockSize * 2)], payload2); + Assert.Equal(SHA256.HashData(bytes.AsMemory().Slice(blockSize, blockSize).ToArray()), SHA256.HashData(payload2)); Assert.True(header2.Attributes.Count == 0); // last part @@ -124,7 +124,7 @@ public class ObjectTest : ObjectTestsBase Assert.NotNull(header3.Split.ParentHeader); Assert.NotNull(header3.Split.ParentSignature); Assert.Equal(header2.Split.SplitId, header3.Split.SplitId); - Assert.Equal(bytes[(fileLength / blockSize * blockSize)..fileLength], payload3); + Assert.Equal(SHA256.HashData(bytes.AsMemory().Slice(fileLength - fileLength % blockSize, fileLength % blockSize).ToArray()), SHA256.HashData(payload3)); Assert.True(header3.Attributes.Count == 0); //link object