From 4b89a7fa5c87482379d06d32a5973eefc21bfb8e Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Sun, 22 Mar 2026 17:14:56 +0000 Subject: [PATCH] encoding/decoding of LGET/LNK --- smp-web/src/protocol.ts | 96 +++++++++++++++++++++++++++++++ tests/SMPWebTests.hs | 79 +++++++++++++++++++++---- xftp-web/src/protocol/commands.ts | 4 +- 3 files changed, 165 insertions(+), 14 deletions(-) create mode 100644 smp-web/src/protocol.ts diff --git a/smp-web/src/protocol.ts b/smp-web/src/protocol.ts new file mode 100644 index 000000000..c32a33241 --- /dev/null +++ b/smp-web/src/protocol.ts @@ -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) + } +} diff --git a/tests/SMPWebTests.hs b/tests/SMPWebTests.hs index a36541b51..cf2106343 100644 --- a/tests/SMPWebTests.hs +++ b/tests/SMPWebTests.hs @@ -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 diff --git a/xftp-web/src/protocol/commands.ts b/xftp-web/src/protocol/commands.ts index 3ca43541f..0bb4d6f59 100644 --- a/xftp-web/src/protocol/commands.ts +++ b/xftp-web/src/protocol/commands.ts @@ -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") }