Skip to content

Commit 4b89a7f

Browse files
encoding/decoding of LGET/LNK
1 parent 3eeffff commit 4b89a7f

File tree

3 files changed

+165
-14
lines changed

3 files changed

+165
-14
lines changed

smp-web/src/protocol.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// SMP protocol commands and transmission format.
2+
// Mirrors: Simplex.Messaging.Protocol
3+
4+
import {
5+
Decoder, concatBytes,
6+
encodeBytes, decodeBytes,
7+
decodeLarge
8+
} from "@simplex-chat/xftp-web/dist/protocol/encoding.js"
9+
import {readTag, readSpace} from "@simplex-chat/xftp-web/dist/protocol/commands.js"
10+
11+
// -- Transmission encoding (Protocol.hs:2201-2203)
12+
// encodeTransmission_ v (CorrId corrId, queueId, command) =
13+
// smpEncode (corrId, queueId) <> encodeProtocol v command
14+
15+
export function encodeTransmission(corrId: Uint8Array, entityId: Uint8Array, command: Uint8Array): Uint8Array {
16+
return concatBytes(
17+
encodeBytes(new Uint8Array(0)), // empty auth
18+
encodeBytes(corrId),
19+
encodeBytes(entityId),
20+
command
21+
)
22+
}
23+
24+
// -- Transmission parsing (Protocol.hs:1629-1642)
25+
// For implySessId = True (v7+): no sessId on wire
26+
27+
export interface RawTransmission {
28+
corrId: Uint8Array
29+
entityId: Uint8Array
30+
command: Uint8Array
31+
}
32+
33+
export function decodeTransmission(d: Decoder): RawTransmission {
34+
const _auth = decodeBytes(d) // authenticator (empty for unsigned)
35+
const corrId = decodeBytes(d)
36+
const entityId = decodeBytes(d)
37+
const command = d.takeAll()
38+
return {corrId, entityId, command}
39+
}
40+
41+
// -- SMP command tags
42+
43+
const SPACE = 0x20
44+
45+
function ascii(s: string): Uint8Array {
46+
const buf = new Uint8Array(s.length)
47+
for (let i = 0; i < s.length; i++) buf[i] = s.charCodeAt(i)
48+
return buf
49+
}
50+
51+
// -- LGET command (Protocol.hs:1709)
52+
// No parameters. EntityId carries LinkId in transmission.
53+
54+
export function encodeLGET(): Uint8Array {
55+
return ascii("LGET")
56+
}
57+
58+
// -- LNK response (Protocol.hs:1834)
59+
// LNK sId d -> e (LNK_, ' ', sId, d)
60+
// where d = (EncFixedDataBytes, EncUserDataBytes), both Large-encoded
61+
62+
export interface LNKResponse {
63+
senderId: Uint8Array
64+
encFixedData: Uint8Array
65+
encUserData: Uint8Array
66+
}
67+
68+
export function decodeLNK(d: Decoder): LNKResponse {
69+
const senderId = decodeBytes(d)
70+
const encFixedData = decodeLarge(d)
71+
const encUserData = decodeLarge(d)
72+
return {senderId, encFixedData, encUserData}
73+
}
74+
75+
// -- Response dispatch (same pattern as xftp-web decodeResponse)
76+
77+
export type SMPResponse =
78+
| {type: "LNK", response: LNKResponse}
79+
| {type: "OK"}
80+
| {type: "ERR", message: string}
81+
82+
export function decodeResponse(d: Decoder): SMPResponse {
83+
const tag = readTag(d)
84+
switch (tag) {
85+
case "LNK": {
86+
readSpace(d)
87+
return {type: "LNK", response: decodeLNK(d)}
88+
}
89+
case "OK": return {type: "OK"}
90+
case "ERR": {
91+
readSpace(d)
92+
return {type: "ERR", message: readTag(d)}
93+
}
94+
default: throw new Error("unknown SMP response: " + tag)
95+
}
96+
}

tests/SMPWebTests.hs

Lines changed: 67 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,6 @@
99
module SMPWebTests (smpWebTests) where
1010

1111
import qualified Data.ByteString as B
12-
import Data.Word (Word16)
1312
import Simplex.Messaging.Encoding
1413
import Test.Hspec hiding (it)
1514
import Util
@@ -21,17 +20,73 @@ smpWebDir = "smp-web"
2120
callNode :: String -> IO B.ByteString
2221
callNode = callNode_ smpWebDir
2322

24-
impEnc :: String
25-
impEnc = "import { encodeBytes, encodeWord16 } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
23+
impProto :: String
24+
impProto = "import { encodeTransmission, decodeTransmission, encodeLGET, decodeLNK, decodeResponse } from './dist/protocol.js';"
25+
<> "import { Decoder } from '@simplex-chat/xftp-web/dist/protocol/encoding.js';"
2626

2727
smpWebTests :: SpecWith ()
2828
smpWebTests = describe "SMP Web Client" $ do
29-
describe "xftp-web imports" $ do
30-
it "encodeBytes via xftp-web" $ do
31-
let val = "hello" :: B.ByteString
32-
actual <- callNode $ impEnc <> jsOut ("encodeBytes(" <> jsUint8 val <> ")")
33-
actual `shouldBe` smpEncode val
34-
it "encodeWord16 via xftp-web" $ do
35-
let val = 12345 :: Word16
36-
actual <- callNode $ impEnc <> jsOut ("encodeWord16(" <> show val <> ")")
37-
actual `shouldBe` smpEncode val
29+
describe "protocol" $ do
30+
describe "transmission" $ do
31+
it "encodeTransmission matches Haskell" $ do
32+
let corrId = "1"
33+
entityId = B.pack [1..24]
34+
command = "LGET"
35+
hsEncoded = smpEncode (corrId :: B.ByteString, entityId :: B.ByteString) <> command
36+
tsEncoded <- callNode $ impProto
37+
<> jsOut ("encodeTransmission("
38+
<> jsUint8 corrId <> ","
39+
<> jsUint8 entityId <> ","
40+
<> "new Uint8Array([0x4C,0x47,0x45,0x54])" -- "LGET"
41+
<> ")")
42+
-- TS encodes with empty auth prefix, HS encodeTransmission_ doesn't include auth
43+
-- So TS output = [0x00] ++ hsEncoded
44+
tsEncoded `shouldBe` (B.singleton 0 <> hsEncoded)
45+
46+
it "decodeTransmission parses Haskell-encoded" $ do
47+
let corrId = "abc"
48+
entityId = B.pack [10..33]
49+
command = "TEST"
50+
encoded = smpEncode (B.empty :: B.ByteString) -- empty auth
51+
<> smpEncode corrId
52+
<> smpEncode entityId
53+
<> command
54+
-- TS decodes and returns corrId ++ entityId ++ command concatenated with length prefixes
55+
tsResult <- callNode $ impProto
56+
<> "const t = decodeTransmission(new Decoder(" <> jsUint8 encoded <> "));"
57+
<> jsOut ("new Uint8Array([...t.corrId, ...t.entityId, ...t.command])")
58+
tsResult `shouldBe` (corrId <> entityId <> command)
59+
60+
describe "LGET" $ do
61+
it "encodeLGET produces correct bytes" $ do
62+
tsResult <- callNode $ impProto <> jsOut "encodeLGET()"
63+
tsResult `shouldBe` "LGET"
64+
65+
describe "LNK" $ do
66+
it "decodeLNK parses correctly" $ do
67+
let senderId = B.pack [1..24]
68+
fixedData = B.pack [100..110]
69+
userData = B.pack [200..220]
70+
encoded = smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
71+
tsResult <- callNode $ impProto
72+
<> "const r = decodeLNK(new Decoder(" <> jsUint8 encoded <> "));"
73+
<> jsOut ("new Uint8Array([...r.senderId, ...r.encFixedData, ...r.encUserData])")
74+
tsResult `shouldBe` (senderId <> fixedData <> userData)
75+
76+
describe "decodeResponse" $ do
77+
it "decodes LNK response" $ do
78+
let senderId = B.pack [1..24]
79+
fixedData = B.pack [100..110]
80+
userData = B.pack [200..220]
81+
commandBytes = "LNK " <> smpEncode senderId <> smpEncode (Large fixedData) <> smpEncode (Large userData)
82+
tsResult <- callNode $ impProto
83+
<> "const r = decodeResponse(new Decoder(" <> jsUint8 commandBytes <> "));"
84+
<> "if (r.type !== 'LNK') throw new Error('expected LNK, got ' + r.type);"
85+
<> jsOut ("new Uint8Array([...r.response.senderId])")
86+
tsResult `shouldBe` senderId
87+
88+
it "decodes OK response" $ do
89+
tsResult <- callNode $ impProto
90+
<> "const r = decodeResponse(new Decoder(new Uint8Array([0x4F, 0x4B])));" -- "OK"
91+
<> jsOut ("new Uint8Array([r.type === 'OK' ? 1 : 0])")
92+
tsResult `shouldBe` B.singleton 1

xftp-web/src/protocol/commands.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -81,7 +81,7 @@ export function encodePING(): Uint8Array { return ascii("PING") }
8181

8282
// -- Response decoding
8383

84-
function readTag(d: Decoder): string {
84+
export function readTag(d: Decoder): string {
8585
const start = d.offset()
8686
while (d.remaining() > 0) {
8787
if (d.buf[d.offset()] === 0x20 || d.buf[d.offset()] === 0x0a) break
@@ -92,7 +92,7 @@ function readTag(d: Decoder): string {
9292
return s
9393
}
9494

95-
function readSpace(d: Decoder): void {
95+
export function readSpace(d: Decoder): void {
9696
if (d.anyByte() !== 0x20) throw new Error("expected space")
9797
}
9898

0 commit comments

Comments
 (0)