fix(gateway): resolve array attribute constraints by bare name via [] fallback
This commit is contained in:
@@ -196,11 +196,30 @@ public sealed class ConstraintEnforcer(
|
|||||||
|
|
||||||
private GalaxyTagLookup? ResolveTarget(string tagAddress)
|
private GalaxyTagLookup? ResolveTarget(string tagAddress)
|
||||||
{
|
{
|
||||||
GalaxyHierarchyCacheEntry entry = cache.Current;
|
if (string.IsNullOrWhiteSpace(tagAddress))
|
||||||
return !string.IsNullOrWhiteSpace(tagAddress)
|
{
|
||||||
&& entry.Index.TagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup)
|
return null;
|
||||||
? lookup
|
}
|
||||||
: null;
|
|
||||||
|
IReadOnlyDictionary<string, GalaxyTagLookup> tagsByAddress = cache.Current.Index.TagsByAddress;
|
||||||
|
if (tagsByAddress.TryGetValue(tagAddress, out GalaxyTagLookup? lookup))
|
||||||
|
{
|
||||||
|
return lookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Galaxy SQL keys array attributes by their suffixed FullTagReference (e.g. "Obj.Arr[]"),
|
||||||
|
// but callers pass the bare address ("Obj.Arr") before the worker-boundary normalization
|
||||||
|
// runs. Probe the suffixed form so a bare array name resolves to its array attribute,
|
||||||
|
// consistent with ArrayAddressNormalizer. Only build the suffixed string on a direct miss
|
||||||
|
// when the address is not already suffixed, and only accept it when it is truly an array.
|
||||||
|
if (!tagAddress.EndsWith("[]", StringComparison.Ordinal)
|
||||||
|
&& tagsByAddress.TryGetValue(tagAddress + "[]", out GalaxyTagLookup? arrayLookup)
|
||||||
|
&& arrayLookup.Attribute?.IsArray == true)
|
||||||
|
{
|
||||||
|
return arrayLookup;
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesPathOrTag(
|
private static bool MatchesPathOrTag(
|
||||||
|
|||||||
@@ -141,6 +141,112 @@ public sealed class GatewayArrayWriteWiringTests
|
|||||||
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A bare array <c>AddItem2</c> address is normalized to its writable array form on the wire,
|
||||||
|
/// and the normalized address lands in the tracked <see cref="SessionItemRegistration"/>.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task AddItem2_BareArrayAddress_NormalizedOnWireAndInRegistration()
|
||||||
|
{
|
||||||
|
CapturingWorkerClient worker = new();
|
||||||
|
GatewaySession session = CreateReadySession(worker);
|
||||||
|
|
||||||
|
WorkerCommand command = new()
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
AddItem2 = new AddItem2Command
|
||||||
|
{
|
||||||
|
ServerHandle = 1,
|
||||||
|
ItemDefinition = "Obj.Arr",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
worker.NextReply = new WorkerCommandReply
|
||||||
|
{
|
||||||
|
Reply = new MxCommandReply
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
ProtocolStatus = new ProtocolStatus { Code = ProtocolStatusCode.Ok },
|
||||||
|
AddItem2 = new AddItem2Reply { ItemHandle = 43 },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await session.InvokeAsync(command, CancellationToken.None);
|
||||||
|
|
||||||
|
Assert.NotNull(worker.LastCommand);
|
||||||
|
Assert.Equal("Obj.Arr[]", worker.LastCommand!.Command.AddItem2.ItemDefinition);
|
||||||
|
|
||||||
|
MxCommand trackingCopy = new()
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.AddItem2,
|
||||||
|
AddItem2 = new AddItem2Command
|
||||||
|
{
|
||||||
|
ServerHandle = 1,
|
||||||
|
ItemDefinition = "Obj.Arr",
|
||||||
|
},
|
||||||
|
};
|
||||||
|
session.TrackCommandReply(trackingCopy, worker.NextReply.Reply);
|
||||||
|
|
||||||
|
Assert.True(session.TryGetItemRegistration(1, 43, out SessionItemRegistration registration));
|
||||||
|
Assert.Equal("Obj.Arr[]", registration.TagAddress);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A sparse-array entry in a <see cref="WriteBulkCommand"/> is expanded to a full,
|
||||||
|
/// default-filled <see cref="MxArray"/> before reaching the worker; no sparse value is ever
|
||||||
|
/// forwarded inside a bulk write.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task WriteBulk_SparseArrayEntryValue_ExpandedBeforeReachingWorker()
|
||||||
|
{
|
||||||
|
CapturingWorkerClient worker = new();
|
||||||
|
GatewaySession session = CreateReadySession(worker);
|
||||||
|
|
||||||
|
WorkerCommand command = new()
|
||||||
|
{
|
||||||
|
Command = new MxCommand
|
||||||
|
{
|
||||||
|
Kind = MxCommandKind.WriteBulk,
|
||||||
|
WriteBulk = new WriteBulkCommand
|
||||||
|
{
|
||||||
|
ServerHandle = 1,
|
||||||
|
Entries =
|
||||||
|
{
|
||||||
|
new WriteBulkEntry
|
||||||
|
{
|
||||||
|
ItemHandle = 42,
|
||||||
|
Value = new MxValue
|
||||||
|
{
|
||||||
|
SparseArrayValue = new MxSparseArray
|
||||||
|
{
|
||||||
|
ElementDataType = MxDataType.Integer,
|
||||||
|
TotalLength = 4,
|
||||||
|
Elements =
|
||||||
|
{
|
||||||
|
new MxSparseElement
|
||||||
|
{
|
||||||
|
Index = 1,
|
||||||
|
Value = new MxValue { Int32Value = 7 },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
await session.InvokeAsync(command, CancellationToken.None);
|
||||||
|
|
||||||
|
MxValue forwarded = worker.LastCommand!.Command.WriteBulk.Entries[0].Value;
|
||||||
|
Assert.Equal(MxValue.KindOneofCase.ArrayValue, forwarded.KindCase);
|
||||||
|
Assert.Equal(new[] { 0, 7, 0, 0 }, forwarded.ArrayValue.Int32Values.Values);
|
||||||
|
}
|
||||||
|
|
||||||
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
private static GatewaySession CreateReadySession(IWorkerClient workerClient)
|
||||||
{
|
{
|
||||||
GatewaySession session = new(
|
GatewaySession session = new(
|
||||||
|
|||||||
@@ -207,6 +207,62 @@ public sealed class ConstraintEnforcerTests
|
|||||||
Assert.Equal("read_historized_only", failure.ConstraintName);
|
Assert.Equal("read_historized_only", failure.ConstraintName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A bare array attribute address (no trailing <c>[]</c>) resolves through the Galaxy index
|
||||||
|
/// even though arrays are keyed by their suffixed FullTagReference (e.g. "Pump_001.Levels[]").
|
||||||
|
/// Without the <c>[]</c> fallback in ResolveTarget the bare name misses the index and a
|
||||||
|
/// read-constrained key gets a spurious tag_metadata denial for an AddItem it should allow.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckReadTagAsync_WithBareArrayName_ResolvesViaArraySuffixFallback()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||||
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
// A read constraint that covers the Pump_001 subtree; the array attribute is inside it.
|
||||||
|
ReadTagGlobs = ["Pump_001.*"],
|
||||||
|
});
|
||||||
|
|
||||||
|
ConstraintFailure? failure = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"Pump_001.Levels",
|
||||||
|
CancellationToken.None);
|
||||||
|
|
||||||
|
// Before the fix: bare "Pump_001.Levels" misses the index (keyed "Pump_001.Levels[]") and
|
||||||
|
// returns a tag_metadata failure. After the fix: it resolves and is within scope -> null.
|
||||||
|
Assert.Null(failure);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// A bare non-array name that is genuinely absent from the index still resolves to null:
|
||||||
|
/// the <c>[]</c> probe must not manufacture a false positive for a scalar/missing tag.
|
||||||
|
/// </summary>
|
||||||
|
[Fact]
|
||||||
|
public async Task CheckReadTagAsync_WithMissingNonArrayName_StillFailsToResolve()
|
||||||
|
{
|
||||||
|
ConstraintEnforcer enforcer = CreateEnforcer(out _);
|
||||||
|
ApiKeyIdentity identity = CreateIdentity(ApiKeyConstraints.Empty with
|
||||||
|
{
|
||||||
|
ReadTagGlobs = ["Pump_001.*"],
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Pump_001.Scalar" is not in the index, and "Pump_001.Scalar[]" is not either, so the
|
||||||
|
// suffix probe must not resolve it. A genuinely-unknown name behaves the same.
|
||||||
|
ConstraintFailure? missingScalar = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"Pump_001.Scalar",
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.NotNull(missingScalar);
|
||||||
|
Assert.Equal("tag_metadata", missingScalar.ConstraintName);
|
||||||
|
|
||||||
|
ConstraintFailure? unknown = await enforcer.CheckReadTagAsync(
|
||||||
|
identity,
|
||||||
|
"DoesNotExist_999.Whatever",
|
||||||
|
CancellationToken.None);
|
||||||
|
Assert.NotNull(unknown);
|
||||||
|
Assert.Equal("tag_metadata", unknown.ConstraintName);
|
||||||
|
}
|
||||||
|
|
||||||
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
private static ConstraintEnforcer CreateEnforcer(out FakeAuditWriter auditWriter)
|
||||||
{
|
{
|
||||||
auditWriter = new FakeAuditWriter();
|
auditWriter = new FakeAuditWriter();
|
||||||
@@ -276,6 +332,14 @@ public sealed class ConstraintEnforcerTests
|
|||||||
AttributeName = "NonHistorized",
|
AttributeName = "NonHistorized",
|
||||||
FullTagReference = "Pump_001.NonHistorized",
|
FullTagReference = "Pump_001.NonHistorized",
|
||||||
},
|
},
|
||||||
|
new GalaxyAttribute
|
||||||
|
{
|
||||||
|
// Galaxy SQL keys array attributes by their suffixed FullTagReference,
|
||||||
|
// so the index entry is "Pump_001.Levels[]", not the bare name.
|
||||||
|
AttributeName = "Levels",
|
||||||
|
FullTagReference = "Pump_001.Levels[]",
|
||||||
|
IsArray = true,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
new GalaxyObject
|
new GalaxyObject
|
||||||
|
|||||||
Reference in New Issue
Block a user