encoding/decoding of LGET/LNK

This commit is contained in:
Evgeny @ SimpleX Chat
2026-03-22 17:14:56 +00:00
parent 3eefffffa3
commit 4b89a7fa5c
3 changed files with 165 additions and 14 deletions
+96
View File
@@ -0,0 +1,96 @@
// SMP protocol commands and transmission format.
// Mirrors: Simplex.Messaging.Protocol
import {
Decoder, concatBytes,
encodeBytes, decodeBytes,
decodeLarge
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
// -- Transmission encoding (Protocol.hs:2201-2203)
// encodeTransmission_ v (CorrId corrId, queueId, command) =
// smpEncode (corrId, queueId) <> encodeProtocol v command
export function encodeTransmission(corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array): Uint8Array {
return concatBytes(
encodeBytes(new Uint8Array(0)), // empty auth
encodeBytes(corrId),
encodeBytes(entityId),
command
)
}
// -- Transmission parsing (Protocol.hs:1629-1642)
// For implySessId = True (v7+): no sessId on wire
export interface RawTransmission {
corrId: Uint8Array
entityId: Uint8Array
command: Uint8Array
}
export function decodeTransmission(d: Decoder): RawTransmission {
const _auth = decodeBytes(d) // authenticator (empty for unsigned)
const corrId = decodeBytes(d)
const entityId = decodeBytes(d)
const command = d.takeAll()
return {corrId, entityId, command}
}
// -- SMP command tags
const SPACE = 0x20
function ascii(s: string): Uint8Array {
const buf = new Uint8Array(s.length)
for (let i = 0; i < s.length; i++) buf[i] = s.charCodeAt(i)
return buf
}
// -- LGET command (Protocol.hs:1709)
// No parameters. EntityId carries LinkId in transmission.
export function encodeLGET(): Uint8Array {
return ascii("LGET")
}
// -- LNK response (Protocol.hs:1834)
// LNK sId d -> e (LNK_, ' ', sId, d)
// where d = (EncFixedDataBytes, EncUserDataBytes), both Large-encoded
export interface LNKResponse {
senderId: Uint8Array
encFixedData: Uint8Array
encUserData: Uint8Array
}
export function decodeLNK(d: Decoder): LNKResponse {
const senderId = decodeBytes(d)
const encFixedData = decodeLarge(d)
const encUserData = decodeLarge(d)
return {senderId, encFixedData, encUserData}
}
// -- Response dispatch (same pattern as xftp-web decodeResponse)
export type SMPResponse =
| {type: "LNK", response: LNKResponse}
| {type: "OK"}
| {type: "ERR", message: string}
export function decodeResponse(d: Decoder): SMPResponse {
const tag = readTag(d)
switch (tag) {
case "LNK": {
readSpace(d)
return {type: "LNK", response: decodeLNK(d)}
}
case "OK": return {type: "OK"}
case "ERR": {
readSpace(d)
return {type: "ERR", message: readTag(d)}
}
default: throw new Error("unknown SMP response: " + tag)
}
}
+67 -12
View File
@@ -9,7 +9,6 @@
module SMPWebTests (smpWebTests) where
import qualified Data.ByteString as B
import Data.Word (Word16)
import Simplex.Messaging.Encoding
import Test.Hspec hiding (it)
import Util
@@ -21,17 +20,73 @@ smpWebDir = "smp-web"
callNode :: String -> IO B.ByteString
callNode = callNode_ smpWebDir
impEnc :: String
impEnc = "import { encodeBytes, encodeWord16 } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
impProto :: String
impProto = "import { encodeTransmission, decodeTransmission, encodeLGET, decodeLNK, decodeResponse } from './dist/protocol.js';"
<> "import { Decoder } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
smpWebTests :: SpecWith ()
smpWebTests = describe "SMP Web Client" $ do
describe "xftp-web imports" $ do
it "encodeBytes via xftp-web" $ do
let val = "hello" :: B.ByteString
actual <- callNode $ impEnc <> jsOut ("encodeBytes(" <> jsUint8 val <> ")")
actual `shouldBe` smpEncode val
it "encodeWord16 via xftp-web" $ do
let val = 12345 :: Word16
actual <- callNode $ impEnc <> jsOut ("encodeWord16(" <> show val <> ")")
actual `shouldBe` smpEncode val
describe "protocol" $ do
describe "transmission" $ do
it "encodeTransmission matches Haskell" $ do
let corrId = "1"
entityId = B.pack [1..24]
command = "LGET"
hsEncoded = smpEncode (corrId :: B.ByteString, entityId :: B.ByteString) <> command
tsEncoded <- callNode $ impProto
<> jsOut ("encodeTransmission("
<> jsUint8 corrId <> ","
<> jsUint8 entityId <> ","
<> "new Uint8Array([0x4C,0x47,0x45,0x54])" -- "LGET"
<> ")")
-- TS encodes with empty auth prefix, HS encodeTransmission_ doesn't include auth
-- So TS output = [0x00] ++ hsEncoded
tsEncoded `shouldBe` (B.singleton 0 <> hsEncoded)
it "decodeTransmission parses Haskell-encoded" $ do
let corrId = "abc"
entityId = B.pack [10..33]
command = "TEST"
encoded = smpEncode (B.empty :: B.ByteString) -- empty auth
<> smpEncode corrId
<> smpEncode entityId
<> command
-- TS decodes and returns corrId ++ entityId ++ command concatenated with length prefixes
tsResult <- callNode $ impProto
<> "const t = decodeTransmission(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array([...t.corrId, ...t.entityId, ...t.command])")
tsResult `shouldBe` (corrId <> entityId <> command)
describe "LGET" $ do
it "encodeLGET produces correct bytes" $ do
tsResult <- callNode $ impProto <> jsOut "encodeLGET()"
tsResult `shouldBe` "LGET"
describe "LNK" $ do
it "decodeLNK parses correctly" $ do
let senderId = B.pack [1..24]
fixedData = B.pack [100..110]
userData = B.pack [200..220]
encoded = smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
tsResult <- callNode $ impProto
<> "const r = decodeLNK(new Decoder(" <> jsUint8 encoded <> "));"
<> jsOut ("new Uint8Array([...r.senderId, ...r.encFixedData, ...r.encUserData])")
tsResult `shouldBe` (senderId <> fixedData <> userData)
describe "decodeResponse" $ do
it "decodes LNK response" $ do
let senderId = B.pack [1..24]
fixedData = B.pack [100..110]
userData = B.pack [200..220]
commandBytes = "LNK " <> smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
tsResult <- callNode $ impProto
<> "const r = decodeResponse(new Decoder(" <> jsUint8 commandBytes <> "));"
<> "if (r.type !== 'LNK') throw new Error('expected LNK, got ' + r.type);"
<> jsOut ("new Uint8Array([...r.response.senderId])")
tsResult `shouldBe` senderId
it "decodes OK response" $ do
tsResult <- callNode $ impProto
<> "const r = decodeResponse(new Decoder(new Uint8Array([0x4F, 0x4B])));" -- "OK"
<> jsOut ("new Uint8Array([r.type === 'OK' ? 1 : 0])")
tsResult `shouldBe` B.singleton 1
+2 -2
View File
@@ -81,7 +81,7 @@ export function encodePING(): Uint8Array { return ascii("PING") }
// -- Response decoding
function readTag(d: Decoder): string {
export function readTag(d: Decoder): string {
const start = d.offset()
while (d.remaining() > 0) {
if (d.buf[d.offset()] === 0x20 || d.buf[d.offset()] === 0x0a) break
@@ -92,7 +92,7 @@ function readTag(d: Decoder): string {
return s
}
function readSpace(d: Decoder): void {
export function readSpace(d: Decoder): void {
if (d.anyByte() !== 0x20) throw new Error("expected space")
}