@@ -16,6 +16,7 @@ import {
1616 deserializePayload ,
1717 type BinaryFrame ,
1818} from "../src/ipc-binary.js" ;
19+ import { fnv1aHash } from "../src/runtime.js" ;
1920
2021function roundtrip ( frame : BinaryFrame ) : void {
2122 const encoded = encodeFrame ( frame ) ;
@@ -751,3 +752,189 @@ describe("V8 serialize/deserialize payload integration", () => {
751752 }
752753 } ) ;
753754} ) ;
755+
756+ // -- Overflow guards --
757+
758+ describe ( "overflow guards" , ( ) => {
759+ it ( "encodeSessionId throws on >255 byte session ID" , ( ) => {
760+ // 256 ASCII chars → 256 bytes UTF-8
761+ const longSid = "x" . repeat ( 256 ) ;
762+ expect ( ( ) =>
763+ encodeFrame ( {
764+ type : "DestroySession" ,
765+ sessionId : longSid ,
766+ } ) ,
767+ ) . toThrow ( "Session ID byte length 256 exceeds maximum 255" ) ;
768+ } ) ;
769+
770+ it ( "encodeSessionId allows exactly 255 byte session ID" , ( ) => {
771+ const sid255 = "a" . repeat ( 255 ) ;
772+ expect ( ( ) =>
773+ encodeFrame ( { type : "DestroySession" , sessionId : sid255 } ) ,
774+ ) . not . toThrow ( ) ;
775+ } ) ;
776+
777+ it ( "encodeSessionId counts UTF-8 bytes not characters" , ( ) => {
778+ // Each emoji is 4 bytes in UTF-8 — 64 emojis = 256 bytes → should throw
779+ const emojiSid = "\u{1F600}" . repeat ( 64 ) ;
780+ expect ( Buffer . byteLength ( emojiSid , "utf8" ) ) . toBe ( 256 ) ;
781+ expect ( ( ) =>
782+ encodeFrame ( { type : "DestroySession" , sessionId : emojiSid } ) ,
783+ ) . toThrow ( "exceeds maximum 255" ) ;
784+ } ) ;
785+
786+ it ( "writeLenPrefixedU16 throws on >65535 byte string" , ( ) => {
787+ const longStr = "x" . repeat ( 65536 ) ;
788+ expect ( ( ) =>
789+ encodeFrame ( {
790+ type : "ExecutionResult" ,
791+ sessionId : "s" ,
792+ exitCode : 1 ,
793+ exports : null ,
794+ error : {
795+ errorType : longStr ,
796+ message : "" ,
797+ stack : "" ,
798+ code : "" ,
799+ } ,
800+ } ) ,
801+ ) . toThrow ( "String byte length 65536 exceeds maximum 65535" ) ;
802+ } ) ;
803+
804+ it ( "writeLenPrefixedU16 allows exactly 65535 byte string" , ( ) => {
805+ const str65535 = "a" . repeat ( 65535 ) ;
806+ expect ( ( ) =>
807+ encodeFrame ( {
808+ type : "ExecutionResult" ,
809+ sessionId : "s" ,
810+ exitCode : 1 ,
811+ exports : null ,
812+ error : {
813+ errorType : str65535 ,
814+ message : "" ,
815+ stack : "" ,
816+ code : "" ,
817+ } ,
818+ } ) ,
819+ ) . not . toThrow ( ) ;
820+ } ) ;
821+ } ) ;
822+
823+ // -- readLenPrefixedU16 position advance --
824+
825+ describe ( "readLenPrefixedU16 position advance" , ( ) => {
826+ it ( "round-trips ExecutionResult with multi-byte UTF-8 error strings" , ( ) => {
827+ // Multi-byte UTF-8: each char is 3 bytes in UTF-8 but 1 char in JS
828+ const frame : BinaryFrame = {
829+ type : "ExecutionResult" ,
830+ sessionId : "s" ,
831+ exitCode : 1 ,
832+ exports : null ,
833+ error : {
834+ errorType : "TypeError" ,
835+ message : "变量未定义" ,
836+ stack : "在文件第一行" ,
837+ code : "ERR_UNDEFINED" ,
838+ } ,
839+ } ;
840+ roundtrip ( frame ) ;
841+ } ) ;
842+
843+ it ( "round-trips ExecutionResult with emoji in error fields" , ( ) => {
844+ const frame : BinaryFrame = {
845+ type : "ExecutionResult" ,
846+ sessionId : "sess-emoji" ,
847+ exitCode : 1 ,
848+ exports : null ,
849+ error : {
850+ errorType : "Error" ,
851+ message : "Failed \u{1F4A5} boom" ,
852+ stack : "at \u{1F4C4} file.js:1" ,
853+ code : "" ,
854+ } ,
855+ } ;
856+ roundtrip ( frame ) ;
857+ } ) ;
858+
859+ it ( "round-trips ExecutionResult with all error fields containing multi-byte chars" , ( ) => {
860+ const frame : BinaryFrame = {
861+ type : "ExecutionResult" ,
862+ sessionId : "t" ,
863+ exitCode : 1 ,
864+ exports : Buffer . from ( [ 0x01 ] ) ,
865+ error : {
866+ errorType : "Ошибка" ,
867+ message : "не найдено: файл.txt" ,
868+ stack : "в строке 日本語テスト" ,
869+ code : "ENOENT_テスト" ,
870+ } ,
871+ } ;
872+ roundtrip ( frame ) ;
873+ } ) ;
874+ } ) ;
875+
876+ // -- fnv1aHash --
877+
878+ describe ( "fnv1aHash" , ( ) => {
879+ it ( "produces consistent hash for ASCII strings" , ( ) => {
880+ expect ( fnv1aHash ( "hello" ) ) . toBe ( fnv1aHash ( "hello" ) ) ;
881+ expect ( fnv1aHash ( "hello" ) ) . not . toBe ( fnv1aHash ( "world" ) ) ;
882+ } ) ;
883+
884+ it ( "produces same hash as Rust FNV-1a over UTF-8 bytes for ASCII" , ( ) => {
885+ // FNV-1a 32-bit of "hello" over UTF-8 bytes [0x68, 0x65, 0x6c, 0x6c, 0x6f]:
886+ // hash = 0x811c9dc5
887+ // hash ^= 0x68; hash *= 0x01000193 → ...
888+ // Expected: 0x4f9f2cab (computed from reference implementation)
889+ const bytes = Buffer . from ( "hello" , "utf8" ) ;
890+ let expected = 0x811c9dc5 ;
891+ for ( let i = 0 ; i < bytes . length ; i ++ ) {
892+ expected ^= bytes [ i ] ;
893+ expected = Math . imul ( expected , 0x01000193 ) ;
894+ }
895+ expected = expected >>> 0 ;
896+ expect ( fnv1aHash ( "hello" ) ) . toBe ( expected ) ;
897+ } ) ;
898+
899+ it ( "hashes over UTF-8 bytes for non-ASCII strings" , ( ) => {
900+ // "é" is 2 bytes in UTF-8 (0xc3 0xa9) but 1 code unit in UTF-16
901+ // If we hashed over UTF-16, we'd get a different result than UTF-8
902+ const bytes = Buffer . from ( "café" , "utf8" ) ;
903+ let expected = 0x811c9dc5 ;
904+ for ( let i = 0 ; i < bytes . length ; i ++ ) {
905+ expected ^= bytes [ i ] ;
906+ expected = Math . imul ( expected , 0x01000193 ) ;
907+ }
908+ expected = expected >>> 0 ;
909+ expect ( fnv1aHash ( "café" ) ) . toBe ( expected ) ;
910+ // Verify it's 5 bytes, not 4 code units
911+ expect ( bytes . length ) . toBe ( 5 ) ;
912+ } ) ;
913+
914+ it ( "produces different hash for non-ASCII vs naive charCodeAt approach" , ( ) => {
915+ // Verify the fix matters: naive charCodeAt gives different result for non-ASCII
916+ const str = "日本語" ;
917+ const bytes = Buffer . from ( str , "utf8" ) ;
918+
919+ // UTF-8 bytes hash (correct)
920+ let utf8Hash = 0x811c9dc5 ;
921+ for ( let i = 0 ; i < bytes . length ; i ++ ) {
922+ utf8Hash ^= bytes [ i ] ;
923+ utf8Hash = Math . imul ( utf8Hash , 0x01000193 ) ;
924+ }
925+ utf8Hash = utf8Hash >>> 0 ;
926+
927+ // UTF-16 code units hash (old buggy behavior)
928+ let utf16Hash = 0x811c9dc5 ;
929+ for ( let i = 0 ; i < str . length ; i ++ ) {
930+ utf16Hash ^= str . charCodeAt ( i ) ;
931+ utf16Hash = Math . imul ( utf16Hash , 0x01000193 ) ;
932+ }
933+ utf16Hash = utf16Hash >>> 0 ;
934+
935+ // They should differ for non-ASCII
936+ expect ( utf8Hash ) . not . toBe ( utf16Hash ) ;
937+ // Our function should match the UTF-8 version
938+ expect ( fnv1aHash ( str ) ) . toBe ( utf8Hash ) ;
939+ } ) ;
940+ } ) ;
0 commit comments