Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 15 additions & 6 deletions MeshCore/Sources/MeshCore/Protocol/PacketBuilder.swift
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,15 @@ public enum PacketBuilder: Sendable {

/// Size of a public key in bytes.
static let publicKeySize = 32
static let rawDataMaxPathBytes = 64
static let rawDataMaxPayloadBytes = 184

private static func encodePublicKey(_ publicKey: Data) -> Data {
if publicKey.count >= publicKeySize {
return publicKey.prefix(publicKeySize)
}
return publicKey + Data(repeating: 0, count: publicKeySize - publicKey.count)
}

// MARK: - Device Commands

Expand Down Expand Up @@ -790,7 +799,7 @@ public enum PacketBuilder: Sendable {
/// - Offset 1 (1 byte): Reserved `0x00`
/// - Offset 2 (1 byte): Mode value (0, 1, or 2)
public static func setPathHashMode(_ mode: UInt8) -> Data {
Data([CommandCode.setPathHashMode.rawValue, 0x00, mode])
Data([CommandCode.setPathHashMode.rawValue, 0x00, min(mode, 2)])
}

/// Builds a factoryReset command to wipe all settings and data from the device.
Expand Down Expand Up @@ -864,10 +873,10 @@ public enum PacketBuilder: Sendable {
/// - Offset 2+N (M bytes): Payload
public static func sendRawData(path: Data, payload: Data) -> Data {
var data = Data([CommandCode.sendRawData.rawValue])
let clampedPath = path.prefix(255)
let clampedPath = path.prefix(rawDataMaxPathBytes)
data.append(UInt8(clampedPath.count))
data.append(clampedPath)
data.append(payload)
data.append(payload.prefix(rawDataMaxPayloadBytes))
return data
}

Expand All @@ -881,7 +890,7 @@ public enum PacketBuilder: Sendable {
/// - Offset 1 (32 bytes): Full public key
public static func hasConnection(publicKey: Data) -> Data {
var data = Data([CommandCode.hasConnection.rawValue])
data.append(publicKey.prefix(publicKeySize))
data.append(encodePublicKey(publicKey))
return data
}

Expand All @@ -895,7 +904,7 @@ public enum PacketBuilder: Sendable {
/// - Offset 1 (32 bytes): Full public key
public static func getContactByKey(publicKey: Data) -> Data {
var data = Data([CommandCode.getContactByKey.rawValue])
data.append(publicKey.prefix(publicKeySize))
data.append(encodePublicKey(publicKey))
return data
}

Expand All @@ -910,7 +919,7 @@ public enum PacketBuilder: Sendable {
/// - Offset 2 (32 bytes): Full public key
public static func getAdvertPath(publicKey: Data) -> Data {
var data = Data([CommandCode.getAdvertPath.rawValue, 0x00])
data.append(publicKey.prefix(publicKeySize))
data.append(encodePublicKey(publicKey))
return data
}

Expand Down
6 changes: 6 additions & 0 deletions MeshCore/Sources/MeshCore/Protocol/PacketParser.swift
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,12 @@ extension PacketParser {
reason: "Battery response too short: \(payload.count) < \(PacketSize.batteryMinimum)"
)
}
if payload.count > PacketSize.batteryMinimum && payload.count < PacketSize.batteryExtended {
return .parseFailure(
data: payload,
reason: "Battery response has partial extended payload: \(payload.count) < \(PacketSize.batteryExtended)"
)
}
let level = Int(payload.readUInt16LE(at: 0))
var usedKB: Int?
var totalKB: Int?
Expand Down
55 changes: 51 additions & 4 deletions MeshCore/Sources/MeshCore/Protocol/Parsers.swift
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,7 @@ public enum Parsers {
let type = ContactType(rawValue: data[offset]) ?? .chat; offset += 1
let flags = ContactFlags(rawValue: data[offset]); offset += 1
let pathLen = data[offset]; offset += 1
guard pathLen == 0xFF || decodePathLen(pathLen) != nil else { return nil }
let actualPathLen = (pathLen == 0xFF) ? 0 : (decodePathLen(pathLen)?.byteLength ?? 0)
// Read full 64-byte path field, but only use first actualPathLen bytes
let pathBytes = Data(data[offset..<offset+64])
Expand Down Expand Up @@ -188,6 +189,15 @@ public enum Parsers {
/// - Parameter data: Raw contact data.
/// - Returns: A `.contact` event or `.parseFailure`.
static func parse(_ data: Data) -> MeshEvent {
if data.count >= PacketSize.contact {
let pathLen = data[34]
if pathLen != 0xFF && decodePathLen(pathLen) == nil {
return .parseFailure(
data: data,
reason: "Contact response uses reserved path length encoding: 0x\(String(format: "%02X", pathLen))"
)
}
}
guard let contact = parseContactData(data) else {
return .parseFailure(
data: data,
Expand Down Expand Up @@ -296,6 +306,13 @@ public enum Parsers {
var model: String?
var version: String?

if fwVer >= 3 && data.count < PacketSize.deviceInfoV3Full {
return .parseFailure(
data: data,
reason: "DeviceInfo v\(fwVer) response too short: \(data.count) < \(PacketSize.deviceInfoV3Full)"
)
}

// v3+ format: fwBuild=12, model=40, version=20 bytes
if fwVer >= 3 && data.count >= PacketSize.deviceInfoV3Full {
maxContacts = Int(data[offset]) * 2 /// Stored as count/2 in firmware.
Expand All @@ -320,14 +337,26 @@ public enum Parsers {

// v9+: client_repeat byte after version string
var clientRepeat = false
if fwVer >= 9 && data.count > offset {
if fwVer >= 9 {
guard data.count > offset else {
return .parseFailure(
data: data,
reason: "DeviceInfo v\(fwVer) missing client_repeat byte"
)
}
clientRepeat = data[offset] != 0
offset += 1
}

// v10+: path_hash_mode byte after client_repeat
var pathHashMode: UInt8 = 0
if fwVer >= 10 && data.count > offset {
if fwVer >= 10 {
guard data.count > offset else {
return .parseFailure(
data: data,
reason: "DeviceInfo v\(fwVer) missing pathHashMode byte"
)
}
pathHashMode = data[offset]
}

Expand Down Expand Up @@ -391,7 +420,13 @@ public enum Parsers {
let timestamp = Date(timeIntervalSince1970: TimeInterval(data.readUInt32LE(at: offset))); offset += 4

var signature: Data?
if txtType == 2 && data.count >= offset + 4 {
if txtType == 2 {
guard data.count >= offset + 4 else {
return .parseFailure(
data: data,
reason: "ContactMessage signature truncated: \(data.count) < \(offset + 4)"
)
}
signature = Data(data[offset..<offset+4]); offset += 4
}

Expand Down Expand Up @@ -1324,7 +1359,19 @@ public enum Parsers {

let timestamp = data.readUInt32LE(at: 0)
let pathLen = data[4]
let byteLen = decodePathLen(pathLen)?.byteLength ?? 0
guard let decoded = decodePathLen(pathLen) else {
return .parseFailure(
data: data,
reason: "AdvertPathResponse uses reserved path length encoding: 0x\(String(format: "%02X", pathLen))"
)
}
let byteLen = decoded.byteLength
guard data.count >= 5 + byteLen else {
return .parseFailure(
data: data,
reason: "AdvertPathResponse path truncated: \(data.count) < \(5 + byteLen)"
)
}
let path = Data(data.dropFirst(5).prefix(byteLen))

return .advertPathResponse(MeshCore.AdvertPathResponse(
Expand Down
31 changes: 26 additions & 5 deletions MeshCore/Sources/MeshCore/Session/MeshCoreSession.swift
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond.
/// ``MeshCoreError/deviceError(code:)`` if the device returns an error.
public func getContact(publicKey: Data) async throws -> MeshContact? {
try requireFullPublicKey(publicKey, operation: "getContact")
let data = PacketBuilder.getContactByKey(publicKey: publicKey)
return try await sendAndWait(data) { event in
switch event {
Expand Down Expand Up @@ -873,8 +874,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Throws: ``MeshCoreError/timeout`` if no response within the timeout period.
/// ``MeshCoreError/invalidResponse`` if an unexpected response is received.
public func requestStatus(from publicKey: Data) async throws -> StatusResponse {
try requireFullPublicKey(publicKey, operation: "requestStatus")
// Serialize binary requests to prevent messageSent race conditions
try await binaryRequestSerializer.withSerialization { [self] in
return try await binaryRequestSerializer.withSerialization { [self] in
try await performStatusRequest(from: publicKey)
}
}
Expand Down Expand Up @@ -998,6 +1000,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Returns: Information about the sent message.
/// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond.
public func sendKeepAlive(to publicKey: Data, syncSince: UInt32) async throws -> MessageSentInfo {
try requireFullPublicKey(publicKey, operation: "sendKeepAlive")
var syncSinceLE = syncSince.littleEndian
let payload = withUnsafeBytes(of: &syncSinceLE) { Data($0) }
let data = PacketBuilder.binaryRequest(to: publicKey, type: .keepAlive, payload: payload)
Expand Down Expand Up @@ -1386,6 +1389,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Parameter publicKey: The full 32-byte public key of the contact.
/// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure.
public func resetPath(publicKey: Data) async throws {
try requireFullPublicKey(publicKey, operation: "resetPath")
try await sendSimpleCommand(PacketBuilder.resetPath(publicKey: publicKey))
}

Expand All @@ -1394,6 +1398,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Parameter publicKey: The full 32-byte public key of the contact to remove.
/// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure.
public func removeContact(publicKey: Data) async throws {
try requireFullPublicKey(publicKey, operation: "removeContact")
try await sendSimpleCommand(PacketBuilder.removeContact(publicKey: publicKey))
}

Expand All @@ -1405,6 +1410,7 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Parameter publicKey: The full 32-byte public key of the contact to share.
/// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure.
public func shareContact(publicKey: Data) async throws {
try requireFullPublicKey(publicKey, operation: "shareContact")
try await sendSimpleCommand(PacketBuilder.shareContact(publicKey: publicKey))
}

Expand All @@ -1414,7 +1420,10 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Returns: A URI string encoding the contact information.
/// - Throws: ``MeshCoreError/timeout`` if the device doesn't respond.
public func exportContact(publicKey: Data? = nil) async throws -> String {
try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in
if let publicKey {
try requireFullPublicKey(publicKey, operation: "exportContact")
}
return try await sendAndWait(PacketBuilder.exportContact(publicKey: publicKey)) { event in
if case .contactURI(let uri) = event { return uri }
return nil
}
Expand Down Expand Up @@ -1819,6 +1828,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Parameter mode: Hash mode (0=1-byte, 1=2-byte, 2=3-byte hashes).
/// - Throws: ``MeshCoreError/timeout`` or ``MeshCoreError/deviceError(code:)`` on failure.
public func setPathHashMode(_ mode: UInt8) async throws {
guard mode <= 2 else {
throw MeshCoreError.invalidInput("Path hash mode must be 0, 1, or 2")
}
try await sendSimpleCommand(PacketBuilder.setPathHashMode(mode))
}

Expand Down Expand Up @@ -1870,8 +1882,9 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Throws: ``MeshCoreError/timeout`` if no response within timeout period.
/// ``MeshCoreError/invalidResponse`` if unexpected response received.
public func requestTelemetry(from publicKey: Data) async throws -> TelemetryResponse {
try requireFullPublicKey(publicKey, operation: "requestTelemetry")
// Serialize binary requests to prevent messageSent race conditions
try await binaryRequestSerializer.withSerialization { [self] in
return try await binaryRequestSerializer.withSerialization { [self] in
try await performTelemetryRequest(from: publicKey)
}
}
Expand Down Expand Up @@ -1990,7 +2003,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Returns: MMA response containing aggregated statistics.
/// - Throws: ``MeshCoreError/timeout`` if no response within timeout period.
public func requestMMA(from publicKey: Data, start: Date, end: Date) async throws -> MMAResponse {
try await binaryRequestSerializer.withSerialization { [self] in
try requireFullPublicKey(publicKey, operation: "requestMMA")
return try await binaryRequestSerializer.withSerialization { [self] in
try await performMMARequest(from: publicKey, start: start, end: end)
}
}
Expand Down Expand Up @@ -2073,7 +2087,8 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
/// - Returns: ACL response containing authorized public keys.
/// - Throws: ``MeshCoreError/timeout`` if no response within timeout period.
public func requestACL(from publicKey: Data) async throws -> ACLResponse {
try await binaryRequestSerializer.withSerialization { [self] in
try requireFullPublicKey(publicKey, operation: "requestACL")
return try await binaryRequestSerializer.withSerialization { [self] in
try await performACLRequest(from: publicKey)
}
}
Expand Down Expand Up @@ -2581,6 +2596,12 @@ public actor MeshCoreSession: MeshCoreSessionProtocol {
break
}
}

private func requireFullPublicKey(_ publicKey: Data, operation: String) throws {
guard publicKey.count == PacketBuilder.publicKeySize else {
throw MeshCoreError.invalidInput("Full \(PacketBuilder.publicKeySize)-byte public key required for \(operation)")
}
}
}

// MARK: - Configuration Types
Expand Down
53 changes: 53 additions & 0 deletions MeshCore/Tests/MeshCoreTests/Protocol/NewCommandsTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,18 @@ struct NewCommandsTests {
#expect(packet.count == 3, "command + pathLen + payload")
}

@Test("sendRawData clamps to firmware limits")
func sendRawDataClampsToFirmwareLimits() {
let path = Data(repeating: 0x11, count: 80)
let payload = Data(repeating: 0x22, count: 220)

let packet = PacketBuilder.sendRawData(path: path, payload: payload)

#expect(packet[1] == 64, "Path length should clamp to firmware 64-byte limit")
#expect(Data(packet[2..<66]) == Data(repeating: 0x11, count: 64), "Path bytes should be truncated to 64 bytes")
#expect(Data(packet[66...]) == Data(repeating: 0x22, count: 184), "Payload should be truncated to firmware 184-byte limit")
}

@Test("hasConnection format")
func hasConnectionFormat() {
let pubkey = Data(repeating: 0xAA, count: 32)
Expand All @@ -36,6 +48,17 @@ struct NewCommandsTests {
#expect(Data(packet[1...]) == pubkey, "Public key")
}

@Test("hasConnection pads short public key to protocol width")
func hasConnectionPadsShortPublicKey() {
let pubkey = Data(repeating: 0xAA, count: 31)

let packet = PacketBuilder.hasConnection(publicKey: pubkey)

#expect(packet.count == 33, "Packet should still contain a full 32-byte key field")
#expect(Data(packet[1..<32]) == Data(repeating: 0xAA, count: 31))
#expect(packet[32] == 0x00, "Short public keys should be zero-padded at the builder layer")
}

@Test("getContactByKey format")
func getContactByKeyFormat() {
let pubkey = Data(repeating: 0xBB, count: 32)
Expand All @@ -47,6 +70,17 @@ struct NewCommandsTests {
#expect(Data(packet[1...]) == pubkey, "Public key")
}

@Test("getContactByKey pads short public key to protocol width")
func getContactByKeyPadsShortPublicKey() {
let pubkey = Data(repeating: 0xBB, count: 31)

let packet = PacketBuilder.getContactByKey(publicKey: pubkey)

#expect(packet.count == 33, "Packet should still contain a full 32-byte key field")
#expect(Data(packet[1..<32]) == Data(repeating: 0xBB, count: 31))
#expect(packet[32] == 0x00)
}

@Test("getAdvertPath format")
func getAdvertPathFormat() {
let pubkey = Data(repeating: 0xCC, count: 32)
Expand All @@ -59,6 +93,17 @@ struct NewCommandsTests {
#expect(Data(packet[2...]) == pubkey, "Public key")
}

@Test("getAdvertPath pads short public key to protocol width")
func getAdvertPathPadsShortPublicKey() {
let pubkey = Data(repeating: 0xCC, count: 31)

let packet = PacketBuilder.getAdvertPath(publicKey: pubkey)

#expect(packet.count == 34, "Packet should still contain reserved byte plus a full 32-byte key field")
#expect(Data(packet[2..<33]) == Data(repeating: 0xCC, count: 31))
#expect(packet[33] == 0x00)
}

@Test("getTuningParams format")
func getTuningParamsFormat() {
let packet = PacketBuilder.getTuningParams()
Expand All @@ -82,4 +127,12 @@ struct NewCommandsTests {
#expect(packet[1] == 0x00, "Reserved byte at offset 1 should be 0x00")
#expect(packet[2] == mode, "Mode byte at offset 2 should be \(mode) (\(label))")
}

@Test("setPathHashMode clamps reserved values to max supported mode")
func setPathHashModeClampsReservedValues() {
let packet = PacketBuilder.setPathHashMode(3)

#expect(packet.count == 3)
#expect(packet[2] == 2, "Reserved path hash modes should clamp to the max supported mode")
}
}
Loading