fix(gateway): resolve 2026-06-18 array-write review findings
- Server-057: extend []-suffix normalization to AddItemBulk/AddBufferedItem so bulk-added array tags bind write-capable handles (authz check, worker bind, and registration kept consistent); update gateway.md + client READMEs. Tests: AddItemBulk/AddBufferedItem wiring. - Server-058: assert []-fallback-resolved bare array names are still denied when out of read/write scope and that MaxWriteClassification is enforced on suffixed array registrations. - Contracts-023/024/025: round-trip + field-19 descriptor pin for MxSparseArray; document MxSparseArray in docs/Contracts.md; enumerate it in the protocol-version-3 test summary. - Tests-040: add wiring tests for the six uncovered sparse-write arms (WriteSecured, Write2, WriteSecured2, Write2Bulk, WriteSecuredBulk, WriteSecured2Bulk). dotnet build + targeted tests green (184 passed).
This commit is contained in:
@@ -987,6 +987,19 @@ public sealed class GatewaySession
|
||||
break;
|
||||
case MxCommand.PayloadOneofCase.AddItem2:
|
||||
command.AddItem2.ItemDefinition = NormalizeAddress(command.AddItem2.ItemDefinition);
|
||||
break;
|
||||
case MxCommand.PayloadOneofCase.AddBufferedItem:
|
||||
command.AddBufferedItem.ItemDefinition = NormalizeAddress(command.AddBufferedItem.ItemDefinition);
|
||||
break;
|
||||
case MxCommand.PayloadOneofCase.AddItemBulk:
|
||||
// Normalize each bare array address in place so the worker binds a write-capable handle
|
||||
// for every array tag in the batch (the same IsArray-gated rewrite the single-add path
|
||||
// applies). Scalar addresses pass through unchanged.
|
||||
for (int i = 0; i < command.AddItemBulk.TagAddresses.Count; i++)
|
||||
{
|
||||
command.AddItemBulk.TagAddresses[i] = NormalizeAddress(command.AddItemBulk.TagAddresses[i]);
|
||||
}
|
||||
|
||||
break;
|
||||
case MxCommand.PayloadOneofCase.Write:
|
||||
ExpandValue(command.Write.Value);
|
||||
@@ -1089,9 +1102,16 @@ public sealed class GatewaySession
|
||||
TrackItem(command.AddItem2.ServerHandle, reply.AddItem2.ItemHandle, NormalizeAddress(command.AddItem2.ItemDefinition));
|
||||
break;
|
||||
case MxCommandKind.AddBufferedItem when reply.AddBufferedItem is not null:
|
||||
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, command.AddBufferedItem.ItemDefinition);
|
||||
// The reply carries no address, so tracking keys off the command's ItemDefinition;
|
||||
// re-apply the array-suffix normalization (the tracking copy is a separate, un-mutated
|
||||
// instance from the one forwarded at the InvokeAsync choke point) so the registration
|
||||
// matches the write-capable handle the worker bound.
|
||||
TrackItem(command.AddBufferedItem.ServerHandle, reply.AddBufferedItem.ItemHandle, NormalizeAddress(command.AddBufferedItem.ItemDefinition));
|
||||
break;
|
||||
case MxCommandKind.AddItemBulk when reply.AddItemBulk is not null:
|
||||
// The worker echoes back the (already-normalized) address it bound in each
|
||||
// SubscribeResult.TagAddress, so TrackBulkItems stores the suffixed array address
|
||||
// without re-normalizing here.
|
||||
TrackBulkItems(reply.AddItemBulk);
|
||||
break;
|
||||
case MxCommandKind.SubscribeBulk when reply.SubscribeBulk is not null:
|
||||
|
||||
@@ -13,15 +13,18 @@ public sealed class GatewayContractInfoTests
|
||||
|
||||
/// <summary>
|
||||
/// Pins the current <see cref="GatewayContractInfo.GatewayProtocolVersion"/>
|
||||
/// constant at 3. Both the alarm proto extension (`AcknowledgeAlarm` /
|
||||
/// `QueryActiveAlarms` RPCs, the `OnAlarmTransitionEvent` body, and the
|
||||
/// alarm command/reply payload cases) and the bulk write/read command
|
||||
/// family extension (`WriteBulk` / `Write2Bulk` / `WriteSecuredBulk` /
|
||||
/// `WriteSecured2Bulk` / `ReadBulk` plus their `BulkWriteReply` and
|
||||
/// `BulkReadReply` payloads) shipped under version 3 — both were strictly
|
||||
/// additive contract changes, so neither required a further bump. A
|
||||
/// future breaking contract change should bump this constant and update
|
||||
/// this test in lock-step.
|
||||
/// constant at 3. All three of the following extensions shipped under
|
||||
/// version 3 as strictly additive contract changes that did not require a
|
||||
/// further bump: (1) the alarm proto extension (<c>AcknowledgeAlarm</c> /
|
||||
/// <c>QueryActiveAlarms</c> RPCs, the <c>OnAlarmTransitionEvent</c> body,
|
||||
/// and the alarm command/reply payload cases); (2) the bulk write/read
|
||||
/// command family (<c>WriteBulk</c> / <c>Write2Bulk</c> /
|
||||
/// <c>WriteSecuredBulk</c> / <c>WriteSecured2Bulk</c> / <c>ReadBulk</c>
|
||||
/// plus their <c>BulkWriteReply</c> and <c>BulkReadReply</c> payloads);
|
||||
/// (3) the sparse-array write ergonomics extension (<c>MxSparseArray</c> /
|
||||
/// <c>MxSparseElement</c> messages plus the <c>sparse_array_value = 19</c>
|
||||
/// arm on the <c>MxValue</c> oneof). A future breaking contract change
|
||||
/// should bump this constant and update this test in lock-step.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void GatewayProtocolVersion_IsVersionThree()
|
||||
|
||||
@@ -1588,4 +1588,87 @@ public sealed class ProtobufContractRoundTripTests
|
||||
Assert.Equal(150UL, parsed.ReplayGap.RequestedAfterSequence);
|
||||
Assert.Equal(200UL, parsed.ReplayGap.OldestAvailableSequence);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pins the wire field number for <c>MxValue.sparse_array_value</c> (19)
|
||||
/// via the generated constant, pins the <see cref="MxSparseArray"/> and
|
||||
/// <see cref="MxSparseElement"/> field numbers by name + number against the
|
||||
/// descriptor, and round-trips an <see cref="MxValue"/> carrying a
|
||||
/// <see cref="MxSparseArray"/> with at least one <see cref="MxSparseElement"/>
|
||||
/// so a future renumber or type-narrowing is caught at the contract level.
|
||||
/// Also verifies that an all-defaults <see cref="MxSparseArray"/> (no elements)
|
||||
/// is not a proto-level error. See Contracts-023.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void MxValue_RoundTripsSparseArrayValueAndPinsFieldNumbers()
|
||||
{
|
||||
// Pin MxValue.sparse_array_value wire field number == 19.
|
||||
Assert.Equal(19, MxValue.SparseArrayValueFieldNumber);
|
||||
|
||||
// Pin MxSparseArray field numbers by name + number.
|
||||
var sparseArrayFields = MxSparseArray.Descriptor.Fields;
|
||||
Assert.Equal(1, sparseArrayFields[MxSparseArray.ElementDataTypeFieldNumber].FieldNumber);
|
||||
Assert.Equal("element_data_type", sparseArrayFields[MxSparseArray.ElementDataTypeFieldNumber].Name);
|
||||
Assert.Equal(2, sparseArrayFields[MxSparseArray.TotalLengthFieldNumber].FieldNumber);
|
||||
Assert.Equal("total_length", sparseArrayFields[MxSparseArray.TotalLengthFieldNumber].Name);
|
||||
Assert.Equal(3, sparseArrayFields[MxSparseArray.ElementsFieldNumber].FieldNumber);
|
||||
Assert.Equal("elements", sparseArrayFields[MxSparseArray.ElementsFieldNumber].Name);
|
||||
|
||||
// Pin MxSparseElement field numbers by name + number.
|
||||
var sparseElementFields = MxSparseElement.Descriptor.Fields;
|
||||
Assert.Equal(1, sparseElementFields[MxSparseElement.IndexFieldNumber].FieldNumber);
|
||||
Assert.Equal("index", sparseElementFields[MxSparseElement.IndexFieldNumber].Name);
|
||||
Assert.Equal(2, sparseElementFields[MxSparseElement.ValueFieldNumber].FieldNumber);
|
||||
Assert.Equal("value", sparseElementFields[MxSparseElement.ValueFieldNumber].Name);
|
||||
|
||||
// Round-trip an MxValue with a populated MxSparseArray (one scalar element).
|
||||
var original = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Float,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 2,
|
||||
Value = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
FloatValue = 3.14f,
|
||||
VariantType = "VT_R4",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
var parsed = MxValue.Parser.ParseFrom(original.ToByteArray());
|
||||
|
||||
Assert.Equal(original, parsed);
|
||||
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, parsed.KindCase);
|
||||
Assert.NotNull(parsed.SparseArrayValue);
|
||||
Assert.Equal(MxDataType.Float, parsed.SparseArrayValue.ElementDataType);
|
||||
Assert.Equal(4u, parsed.SparseArrayValue.TotalLength);
|
||||
var element = Assert.Single(parsed.SparseArrayValue.Elements);
|
||||
Assert.Equal(2u, element.Index);
|
||||
Assert.Equal(3.14f, element.Value.FloatValue);
|
||||
|
||||
// An all-defaults MxSparseArray (no elements) is not a proto-level error.
|
||||
var empty = new MxValue
|
||||
{
|
||||
DataType = MxDataType.Float,
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Float,
|
||||
TotalLength = 10,
|
||||
},
|
||||
};
|
||||
var parsedEmpty = MxValue.Parser.ParseFrom(empty.ToByteArray());
|
||||
Assert.Equal(empty, parsedEmpty);
|
||||
Assert.Equal(MxValue.KindOneofCase.SparseArrayValue, parsedEmpty.KindCase);
|
||||
Assert.Empty(parsedEmpty.SparseArrayValue.Elements);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -247,6 +247,440 @@ public sealed class GatewayArrayWriteWiringTests
|
||||
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare array address added via <c>AddItemBulk</c> is normalized to its writable array form
|
||||
/// on the wire (so the worker registers a write-capable handle), while a scalar address in the
|
||||
/// same batch is forwarded unchanged. Tracking the worker's echoed reply lands the normalized
|
||||
/// address in the <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddItemBulk_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
AddItemBulk = new AddItemBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
// The array address gains the writable "[]" suffix; the scalar passes through unchanged.
|
||||
Assert.NotNull(worker.LastCommand);
|
||||
Assert.Equal(
|
||||
new[] { "Obj.Arr[]", "Obj.Scalar" },
|
||||
worker.LastCommand!.Command.AddItemBulk.TagAddresses);
|
||||
|
||||
// The real worker echoes back the (suffixed) address it bound; tracking the reply must land the
|
||||
// normalized address in the registration so a later write resolves the write-capable handle.
|
||||
MxCommand trackingCopy = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
AddItemBulk = new AddItemBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
TagAddresses = { "Obj.Arr", "Obj.Scalar" },
|
||||
},
|
||||
};
|
||||
MxCommandReply reply = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddItemBulk,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddItemBulk = new BulkSubscribeReply
|
||||
{
|
||||
Results =
|
||||
{
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 50,
|
||||
TagAddress = "Obj.Arr[]",
|
||||
WasSuccessful = true,
|
||||
},
|
||||
new SubscribeResult
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 51,
|
||||
TagAddress = "Obj.Scalar",
|
||||
WasSuccessful = true,
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
session.TrackCommandReply(trackingCopy, reply);
|
||||
|
||||
Assert.True(session.TryGetItemRegistration(1, 50, out SessionItemRegistration arrayRegistration));
|
||||
Assert.Equal("Obj.Arr[]", arrayRegistration.TagAddress);
|
||||
Assert.True(session.TryGetItemRegistration(1, 51, out SessionItemRegistration scalarRegistration));
|
||||
Assert.Equal("Obj.Scalar", scalarRegistration.TagAddress);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="WriteSecuredCommand"/> value is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the secured variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured,
|
||||
WriteSecured = new WriteSecuredCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 2,
|
||||
Value = new MxValue { Int32Value = 9 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 9, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="Write2Command"/> (timestamped) value is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the Write2 variant's
|
||||
/// <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2,
|
||||
Write2 = new Write2Command
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 3,
|
||||
Value = new MxValue { Int32Value = 5 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.Write2.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 0, 5 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array <see cref="WriteSecured2Command"/> (timestamped secured) value is expanded
|
||||
/// to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
|
||||
/// WriteSecured2 variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured2_SparseArrayValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2,
|
||||
WriteSecured2 = new WriteSecured2Command
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 0,
|
||||
Value = new MxValue { Int32Value = 3 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2.Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 3, 0, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="Write2BulkCommand"/> (timestamped) is expanded to a
|
||||
/// full, default-filled <see cref="MxArray"/> before reaching the worker; the Write2Bulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task Write2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.Write2Bulk,
|
||||
Write2Bulk = new Write2BulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new Write2BulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 1,
|
||||
Value = new MxValue { Int32Value = 11 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.Write2Bulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 11, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="WriteSecuredBulkCommand"/> is expanded to a full,
|
||||
/// default-filled <see cref="MxArray"/> before reaching the worker; the WriteSecuredBulk
|
||||
/// variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecuredBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecuredBulk,
|
||||
WriteSecuredBulk = new WriteSecuredBulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecuredBulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 2,
|
||||
Value = new MxValue { Int32Value = 13 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecuredBulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 13, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A sparse-array entry in a <see cref="WriteSecured2BulkCommand"/> (timestamped secured) is
|
||||
/// expanded to a full, default-filled <see cref="MxArray"/> before reaching the worker; the
|
||||
/// WriteSecured2Bulk variant's <c>case</c> arm in <c>NormalizeOutboundCommand</c> is wired to
|
||||
/// the expander.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task WriteSecured2Bulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.WriteSecured2Bulk,
|
||||
WriteSecured2Bulk = new WriteSecured2BulkCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
Entries =
|
||||
{
|
||||
new WriteSecured2BulkEntry
|
||||
{
|
||||
ItemHandle = 42,
|
||||
Value = new MxValue
|
||||
{
|
||||
SparseArrayValue = new MxSparseArray
|
||||
{
|
||||
ElementDataType = MxDataType.Integer,
|
||||
TotalLength = 4,
|
||||
Elements =
|
||||
{
|
||||
new MxSparseElement
|
||||
{
|
||||
Index = 3,
|
||||
Value = new MxValue { Int32Value = 17 },
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
MxValue forwarded = worker.LastCommand!.Command.WriteSecured2Bulk.Entries[0].Value;
|
||||
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||
Assert.Equal(new[] { 0, 0, 0, 17 }, forwarded.ArrayValue.Int32Values.Values);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A bare array address added via <c>AddBufferedItem</c> is normalized to its writable array
|
||||
/// form on the wire and in the tracked <see cref="SessionItemRegistration"/>.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task AddBufferedItem_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||
{
|
||||
CapturingWorkerClient worker = new();
|
||||
GatewaySession session = CreateReadySession(worker);
|
||||
|
||||
WorkerCommand command = new()
|
||||
{
|
||||
Command = new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
worker.NextReply = new WorkerCommandReply
|
||||
{
|
||||
Reply = new MxCommandReply
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||
AddBufferedItem = new AddBufferedItemReply { ItemHandle = 60 },
|
||||
},
|
||||
};
|
||||
|
||||
await session.InvokeAsync(command, CancellationToken.None);
|
||||
|
||||
Assert.NotNull(worker.LastCommand);
|
||||
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddBufferedItem.ItemDefinition);
|
||||
|
||||
// AddBufferedItem tracking keys off the command's ItemDefinition (the reply carries no address),
|
||||
// so the tracking-path normalization must run for the registration to match the bound handle.
|
||||
MxCommand trackingCopy = new()
|
||||
{
|
||||
Kind = MxCommandKind.AddBufferedItem,
|
||||
AddBufferedItem = new AddBufferedItemCommand
|
||||
{
|
||||
ServerHandle = 1,
|
||||
ItemDefinition = "Obj.Arr",
|
||||
},
|
||||
};
|
||||
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
|
||||
|
||||
Assert.True(session.TryGetItemRegistration(1, 60, out SessionItemRegistration registration));
|
||||
Assert.Equal("Obj.Arr[]", registration.TagAddress);
|
||||
}
|
||||
|
||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||
{
|
||||
GatewaySession session = new(
|
||||
|
||||
@@ -263,6 +263,116 @@ public sealed class ConstraintEnforcerTests
|
||||
Assert.Equal("tag_metadata", unknown.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// The <c>[]</c>-suffix fallback widened *resolution* of a bare array name, not *authorization*:
|
||||
/// a bare array attribute that resolves through the fallback but is outside the key's read scope
|
||||
/// must still be denied with a <c>read_scope</c> failure (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckReadTagAsync_WithBareArrayName_OutOfScope_StillDeniedReadScope()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
// Scope covers a different object, so the resolved array attribute is out of scope.
|
||||
ReadTagGlobs = ["Other_001.*"],
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||
identity,
|
||||
"Pump_001.Levels",
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("read_scope", failure.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write against an array handle whose registration carries the suffixed form ("Pump_001.Levels[]")
|
||||
/// resolves through <c>ResolveTarget</c> and is denied with a <c>write_scope</c> failure when the
|
||||
/// array attribute is outside the key's write scope (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_OutOfScope_StillDeniedWriteScope()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
WriteSubtrees = ["Other/*"],
|
||||
});
|
||||
GatewaySession session = CreateSession();
|
||||
session.TrackCommandReply(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 12,
|
||||
// The worker-bound, suffixed array address is what lands in the registration.
|
||||
ItemDefinition = "Pump_001.Levels[]",
|
||||
},
|
||||
},
|
||||
new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(),
|
||||
AddItem = new AddItemReply { ItemHandle = 70 },
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
serverHandle: 12,
|
||||
itemHandle: 70,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("write_scope", failure.ConstraintName);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// A write against an in-scope array handle whose registration carries the suffixed form is still
|
||||
/// denied when the array attribute's <c>SecurityClassification</c> exceeds the key's
|
||||
/// <c>MaxWriteClassification</c>, resolved through <c>ResolveTarget</c> via the suffixed address
|
||||
/// (Server-058).
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public async Task CheckWriteHandleAsync_WithSuffixedArrayRegistration_ClassificationTooHigh_StillDenied()
|
||||
{
|
||||
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||
{
|
||||
// In scope for the array attribute, but the array's classification (2) exceeds the cap (1).
|
||||
WriteTagGlobs = ["Pump_001.*"],
|
||||
MaxWriteClassification = 1,
|
||||
});
|
||||
GatewaySession session = CreateSession();
|
||||
session.TrackCommandReply(
|
||||
new MxCommand
|
||||
{
|
||||
Kind = MxCommandKind.AddItem,
|
||||
AddItem = new AddItemCommand
|
||||
{
|
||||
ServerHandle = 12,
|
||||
ItemDefinition = "Pump_001.Setpoints[]",
|
||||
},
|
||||
},
|
||||
new MxCommandReply
|
||||
{
|
||||
ProtocolStatus = ZB.MOM.WW.MxGateway.Server.Grpc.MxAccessGrpcMapper.Ok(),
|
||||
AddItem = new AddItemReply { ItemHandle = 71 },
|
||||
});
|
||||
|
||||
ConstraintFailure? failure = await enforcer.CheckWriteHandleAsync(
|
||||
identity,
|
||||
session,
|
||||
serverHandle: 12,
|
||||
itemHandle: 71,
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(failure);
|
||||
Assert.Equal("max_write_classification", failure.ConstraintName);
|
||||
}
|
||||
|
||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
||||
{
|
||||
auditWriter = new FakeAuditWriter();
|
||||
@@ -340,6 +450,16 @@ public sealed class ConstraintEnforcerTests
|
||||
FullTagReference = "Pump_001.Levels[]",
|
||||
IsArray = true,
|
||||
},
|
||||
new GalaxyAttribute
|
||||
{
|
||||
// A second array attribute carrying a non-zero security classification so
|
||||
// the MaxWriteClassification check can be exercised via a suffixed
|
||||
// registration address resolving through ResolveTarget.
|
||||
AttributeName = "Setpoints",
|
||||
FullTagReference = "Pump_001.Setpoints[]",
|
||||
IsArray = true,
|
||||
SecurityClassification = 2,
|
||||
},
|
||||
},
|
||||
},
|
||||
new GalaxyObject
|
||||
|
||||
Reference in New Issue
Block a user