Files
RuView/ui/ios-nlos/Sources/RuViewNLOSCore/TrackEnvelopeDecoder.swift

283 lines
12 KiB
Swift

import Foundation
public enum NLOSValidationError: Error, Equatable, LocalizedError, Sendable {
case frameTooLarge(actualBytes: Int, maximumBytes: Int)
case malformedEnvelope
case invalidField(String)
case staleFrame
case futureDatedFrame
case excessiveLifetime
case replayedSequence
case sessionChanged
case insecureEndpoint
case invalidPairingToken
public var errorDescription: String? {
switch self {
case let .frameTooLarge(actualBytes, maximumBytes):
return "Frame is \(actualBytes) bytes; the limit is \(maximumBytes) bytes."
case .malformedEnvelope:
return "Frame is not a valid ruview.nlos.track.v1 envelope."
case let .invalidField(field):
return "Frame failed validation for \(field)."
case .staleFrame:
return "Frame is stale and was hidden."
case .futureDatedFrame:
return "Frame timestamp is outside the allowed clock skew."
case .excessiveLifetime:
return "Frame lifetime exceeds the 5 second safety window."
case .replayedSequence:
return "Frame sequence was repeated or moved backwards."
case .sessionChanged:
return "Stream session changed without reconnecting."
case .insecureEndpoint:
return "Only a bounded wss endpoint without embedded credentials is allowed."
case .invalidPairingToken:
return "Pairing token must be 32 to 512 visible ASCII characters."
}
}
}
public struct TrackEnvelopeDecoder: Sendable {
public static let schema = "ruview.nlos.track.v1"
public static let authenticatedSchema = "ruview.nlos.authenticated.v1"
public static let maximumFrameBytes = 256 * 1024
public static let maximumTracks = 16
public static let maximumLifetimeMs: UInt64 = 5_000
public static let maximumFutureSkewMs: UInt64 = 1_000
public static let maximumAuthenticationLifetimeMs: UInt64 = 60 * 60 * 1_000
public static let maximumInteroperableSequence: UInt64 = 9_007_199_254_740_991
public init() {}
public func decodeAuthenticated(
_ data: Data,
nowUnixMs: UInt64
) throws -> NLOSAuthenticatedSession {
guard data.count <= Self.maximumFrameBytes else {
throw NLOSValidationError.frameTooLarge(
actualBytes: data.count,
maximumBytes: Self.maximumFrameBytes
)
}
let message: NLOSAuthenticatedSession
do {
message = try JSONDecoder().decode(NLOSAuthenticatedSession.self, from: data)
} catch {
throw NLOSValidationError.malformedEnvelope
}
guard message.schema == Self.authenticatedSchema else {
throw NLOSValidationError.invalidField("authenticated.schema")
}
guard isSafeIdentifier(message.sessionId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("authenticated.sessionId")
}
guard message.expiresAtUnixMs > nowUnixMs else {
throw NLOSValidationError.staleFrame
}
guard message.expiresAtUnixMs <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("authenticated.expiresAtUnixMs")
}
guard message.expiresAtUnixMs - nowUnixMs
<= Self.maximumAuthenticationLifetimeMs + Self.maximumFutureSkewMs else {
throw NLOSValidationError.excessiveLifetime
}
return message
}
public func decode(_ data: Data, nowUnixMs: UInt64) throws -> ValidatedTrackEnvelope {
guard data.count <= Self.maximumFrameBytes else {
throw NLOSValidationError.frameTooLarge(
actualBytes: data.count,
maximumBytes: Self.maximumFrameBytes
)
}
let envelope: NLOSTrackEnvelope
do {
envelope = try JSONDecoder().decode(NLOSTrackEnvelope.self, from: data)
} catch {
throw NLOSValidationError.malformedEnvelope
}
try validate(envelope, nowUnixMs: nowUnixMs)
return ValidatedTrackEnvelope(value: envelope)
}
private func validate(_ envelope: NLOSTrackEnvelope, nowUnixMs: UInt64) throws {
guard envelope.schema == Self.schema else {
throw NLOSValidationError.invalidField("schema")
}
guard isSafeIdentifier(envelope.sessionId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("sessionId")
}
guard envelope.sequence <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("sequence")
}
guard envelope.capturedAtUnixMs <= Self.maximumInteroperableSequence,
envelope.expiresAtUnixMs <= Self.maximumInteroperableSequence else {
throw NLOSValidationError.invalidField("timestamp")
}
guard isSafeIdentifier(envelope.algorithmVersion, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("algorithmVersion")
}
guard isLowercaseSHA256(envelope.calibrationHash) else {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.evidenceLevel != .l3Corroborated else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
let zeroHash = String(repeating: "0", count: 64)
if envelope.source == .synthetic {
guard envelope.evidenceLevel == .l0Synthetic else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
guard envelope.calibrationHash == zeroHash else {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.provenance.transport == .replay else {
throw NLOSValidationError.invalidField("provenance.transport")
}
guard envelope.provenance.transientKind == .replay else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
} else if envelope.evidenceLevel >= .l2Calibrated,
envelope.calibrationHash == zeroHash {
throw NLOSValidationError.invalidField("calibrationHash")
}
guard envelope.expiresAtUnixMs > envelope.capturedAtUnixMs else {
throw NLOSValidationError.invalidField("expiresAtUnixMs")
}
guard envelope.expiresAtUnixMs - envelope.capturedAtUnixMs <= Self.maximumLifetimeMs else {
throw NLOSValidationError.excessiveLifetime
}
guard envelope.expiresAtUnixMs > nowUnixMs else {
throw NLOSValidationError.staleFrame
}
if envelope.capturedAtUnixMs > nowUnixMs {
guard envelope.capturedAtUnixMs - nowUnixMs <= Self.maximumFutureSkewMs else {
throw NLOSValidationError.futureDatedFrame
}
}
try validate(envelope.provenance, source: envelope.source, evidence: envelope.evidenceLevel)
guard envelope.tracks.count <= Self.maximumTracks else {
throw NLOSValidationError.invalidField("tracks")
}
var trackIds = Set<String>()
for track in envelope.tracks {
try validate(track)
guard trackIds.insert(track.trackId).inserted else {
throw NLOSValidationError.invalidField("tracks.trackId")
}
}
}
private func validate(
_ provenance: NLOSProvenance,
source: NLOSSource,
evidence: EvidenceLevel
) throws {
guard isSafeIdentifier(provenance.sensorId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.sensorId")
}
guard isSafeIdentifier(provenance.sensorModel, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.sensorModel")
}
guard isSafeIdentifier(provenance.firmwareVersion, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("provenance.firmwareVersion")
}
if source == .live {
guard evidence >= .l1Measured else {
throw NLOSValidationError.invalidField("evidenceLevel")
}
let isLiveHistogram = provenance.transientKind == .rawHistogram
|| provenance.transientKind == .compactNormalizedHistogram
guard provenance.histogramPreserved,
isLiveHistogram,
provenance.transport != .replay else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
} else if source == .replay {
guard provenance.transport == .replay,
provenance.transientKind == .replay,
provenance.histogramPreserved else {
throw NLOSValidationError.invalidField("provenance.transientKind")
}
}
}
private func validate(_ track: NLOSTrack) throws {
guard isSafeIdentifier(track.trackId, maximumBytes: 64) else {
throw NLOSValidationError.invalidField("tracks.trackId")
}
guard isFinite(track.positionM, absoluteMaximum: 100) else {
throw NLOSValidationError.invalidField("tracks.positionM")
}
guard isFinite(track.velocityMps, absoluteMaximum: 20) else {
throw NLOSValidationError.invalidField("tracks.velocityMps")
}
guard isFiniteNonnegative(track.covarianceDiagonalM2, maximum: 10) else {
throw NLOSValidationError.invalidField("tracks.covarianceDiagonalM2")
}
guard isUnitInterval(track.confidence) else {
throw NLOSValidationError.invalidField("tracks.confidence")
}
guard track.posteriorEntropy.isFinite, track.posteriorEntropy >= 0 else {
throw NLOSValidationError.invalidField("tracks.posteriorEntropy")
}
guard isUnitInterval(track.signalQuality) else {
throw NLOSValidationError.invalidField("tracks.signalQuality")
}
guard isUnitInterval(track.modalityContributions.lidar),
isUnitInterval(track.modalityContributions.csi) else {
throw NLOSValidationError.invalidField("tracks.modalityContributions")
}
let contributionSum = track.modalityContributions.lidar
+ track.modalityContributions.csi
guard contributionSum >= 0.999, contributionSum <= 1.001 else {
throw NLOSValidationError.invalidField("tracks.modalityContributions")
}
}
private func isUnitInterval(_ value: Double) -> Bool {
value.isFinite && value >= 0 && value <= 1
}
private func isFinite(_ vector: Vector3, absoluteMaximum: Double) -> Bool {
[vector.x, vector.y, vector.z].allSatisfy {
$0.isFinite && abs($0) <= absoluteMaximum
}
}
private func isFiniteNonnegative(_ vector: Vector3, maximum: Double) -> Bool {
[vector.x, vector.y, vector.z].allSatisfy {
$0.isFinite && $0 >= 0 && $0 <= maximum
}
}
private func isSafeIdentifier(_ value: String, maximumBytes: Int) -> Bool {
guard !value.isEmpty, value.utf8.count <= maximumBytes else { return false }
return value.unicodeScalars.allSatisfy { scalar in
let code = scalar.value
return (code >= 48 && code <= 57)
|| (code >= 65 && code <= 90)
|| (code >= 97 && code <= 122)
|| code == 45
|| code == 46
|| code == 58
|| code == 95
}
}
private func isLowercaseSHA256(_ value: String) -> Bool {
value.utf8.count == 64 && value.utf8.allSatisfy { byte in
(byte >= 48 && byte <= 57) || (byte >= 97 && byte <= 102)
}
}
}