Compare commits

..

No commits in common. "752b91d3295dd3a2bc210ff879fcccd27a825ad3" and "5482283727c127c64b247f45bedfd5a25ceb16d2" have entirely different histories.

18 changed files with 324 additions and 518 deletions

301
Cargo.lock generated
View File

@ -304,7 +304,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0"
dependencies = [ dependencies = [
"crypto-common 0.1.7", "crypto-common 0.1.7",
"generic-array", "generic-array 0.14.7",
] ]
[[package]] [[package]]
@ -1191,7 +1191,7 @@ version = "0.10.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71"
dependencies = [ dependencies = [
"generic-array", "generic-array 0.14.7",
] ]
[[package]] [[package]]
@ -1210,7 +1210,7 @@ version = "0.3.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93"
dependencies = [ dependencies = [
"generic-array", "generic-array 0.14.7",
] ]
[[package]] [[package]]
@ -1653,6 +1653,17 @@ version = "0.8.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
[[package]]
name = "core-models"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0940496e5c83c54f3b753d5317daec82e8edac71c33aaa1f666d76f518de2444"
dependencies = [
"hax-lib",
"pastey",
"rand 0.9.2",
]
[[package]] [[package]]
name = "core2" name = "core2"
version = "0.4.0" version = "0.4.0"
@ -1771,7 +1782,7 @@ version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76" checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [ dependencies = [
"generic-array", "generic-array 0.14.7",
"rand_core 0.6.4", "rand_core 0.6.4",
"subtle", "subtle",
"zeroize", "zeroize",
@ -1783,7 +1794,7 @@ version = "0.1.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a"
dependencies = [ dependencies = [
"generic-array", "generic-array 0.14.7",
"rand_core 0.6.4", "rand_core 0.6.4",
"typenum", "typenum",
] ]
@ -2187,7 +2198,7 @@ dependencies = [
"crypto-bigint", "crypto-bigint",
"digest 0.10.7", "digest 0.10.7",
"ff", "ff",
"generic-array", "generic-array 0.14.7",
"group", "group",
"hkdf", "hkdf",
"pem-rfc7468 0.7.0", "pem-rfc7468 0.7.0",
@ -2651,6 +2662,17 @@ dependencies = [
"zeroize", "zeroize",
] ]
[[package]]
name = "generic-array"
version = "1.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eaf57c49a95fd1fe24b90b3033bee6dc7e8f1288d51494cb44e627c295e38542"
dependencies = [
"generic-array 0.14.7",
"rustversion",
"typenum",
]
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.2.17" version = "0.2.17"
@ -3090,6 +3112,43 @@ dependencies = [
"hashbrown 0.15.5", "hashbrown 0.15.5",
] ]
[[package]]
name = "hax-lib"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "74d9ba66d1739c68e0219b2b2238b5c4145f491ebf181b9c6ab561a19352ae86"
dependencies = [
"hax-lib-macros",
"num-bigint",
"num-traits",
]
[[package]]
name = "hax-lib-macros"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24ba777a231a58d1bce1d68313fa6b6afcc7966adef23d60f45b8a2b9b688bf1"
dependencies = [
"hax-lib-macros-types",
"proc-macro-error2",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "hax-lib-macros-types"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "867e19177d7425140b417cd27c2e05320e727ee682e98368f88b7194e80ad515"
dependencies = [
"proc-macro2",
"quote",
"serde",
"serde_json",
"uuid",
]
[[package]] [[package]]
name = "headers" name = "headers"
version = "0.4.1" version = "0.4.1"
@ -3364,7 +3423,7 @@ dependencies = [
"js-sys", "js-sys",
"log", "log",
"wasm-bindgen", "wasm-bindgen",
"windows-core 0.62.2", "windows-core",
] ]
[[package]] [[package]]
@ -3604,7 +3663,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01"
dependencies = [ dependencies = [
"block-padding", "block-padding",
"generic-array", "generic-array 0.14.7",
] ]
[[package]] [[package]]
@ -3627,9 +3686,9 @@ dependencies = [
[[package]] [[package]]
name = "internal-russh-forked-ssh-key" name = "internal-russh-forked-ssh-key"
version = "0.6.9+upstream-0.6.7" version = "0.6.11+upstream-0.6.7"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fb5af01d366561582e9ea5f841837cc1d8e37e7142a32f33a43801e81863cba5" checksum = "e0a77eae781ed6a7709fb15b64862fcca13d886b07c7e2786f5ed34e5e2b9187"
dependencies = [ dependencies = [
"argon2", "argon2",
"bcrypt-pbkdf", "bcrypt-pbkdf",
@ -3637,7 +3696,6 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"hex", "hex",
"hmac", "hmac",
"num-bigint-dig",
"p256", "p256",
"p384", "p384",
"p521", "p521",
@ -4030,6 +4088,72 @@ version = "0.2.183"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d"
[[package]]
name = "libcrux-intrinsics"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bc9ee7ef66569dd7516454fe26de4e401c0c62073929803486b96744594b9632"
dependencies = [
"core-models",
"hax-lib",
]
[[package]]
name = "libcrux-ml-kem"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4bb6a88086bf11bd2ec90926c749c4a427f2e59841437dbdede8cde8a96334ab"
dependencies = [
"hax-lib",
"libcrux-intrinsics",
"libcrux-platform",
"libcrux-secrets",
"libcrux-sha3",
"libcrux-traits",
"rand 0.9.2",
"tls_codec",
]
[[package]]
name = "libcrux-platform"
version = "0.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db82d058aa76ea315a3b2092f69dfbd67ddb0e462038a206e1dcd73f058c0778"
dependencies = [
"libc",
]
[[package]]
name = "libcrux-secrets"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e4dbbf6bc9f2bc0f20dc3bea3e5c99adff3bdccf6d2a40488963da69e2ec307"
dependencies = [
"hax-lib",
]
[[package]]
name = "libcrux-sha3"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2400bec764d1c75b8a496d5747cffe32f1fb864a12577f0aca2f55a92021c962"
dependencies = [
"hax-lib",
"libcrux-intrinsics",
"libcrux-platform",
"libcrux-traits",
]
[[package]]
name = "libcrux-traits"
version = "0.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9adfd58e79d860f6b9e40e35127bfae9e5bd3ade33201d1347459011a2add034"
dependencies = [
"libcrux-secrets",
"rand 0.9.2",
]
[[package]] [[package]]
name = "libfuzzer-sys" name = "libfuzzer-sys"
version = "0.4.12" version = "0.4.12"
@ -4796,18 +4920,21 @@ dependencies = [
[[package]] [[package]]
name = "pageant" name = "pageant"
version = "0.0.2" version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2c6f0e349ea8dea1b50aa17c082777d30df133d89898c7568a615354772d3731" checksum = "1b537f975f6d8dcf48db368d7ec209d583b015713b5df0f5d92d2631e4ff5595"
dependencies = [ dependencies = [
"byteorder",
"bytes", "bytes",
"delegate", "delegate",
"futures", "futures",
"log", "log",
"rand 0.8.5", "rand 0.8.5",
"sha2 0.10.9",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"windows 0.58.0", "windows",
"windows-strings",
] ]
[[package]] [[package]]
@ -5972,18 +6099,16 @@ dependencies = [
[[package]] [[package]]
name = "russh" name = "russh"
version = "0.50.4" version = "0.55.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "02d8075561703e70dab7b095b2c13597cde37f5be94af0849fa4e51c315020d0" checksum = "82b4d036bb45d7bbe99dbfef4ec60eaeb614708d22ff107124272f8ef6b54548"
dependencies = [ dependencies = [
"aes 0.8.4", "aes 0.8.4",
"aes-gcm 0.10.3",
"bitflags", "bitflags",
"block-padding", "block-padding",
"byteorder", "byteorder",
"bytes", "bytes",
"cbc", "cbc",
"chacha20 0.9.1",
"ctr 0.9.2", "ctr 0.9.2",
"curve25519-dalek", "curve25519-dalek",
"data-encoding", "data-encoding",
@ -5994,30 +6119,29 @@ dependencies = [
"ed25519-dalek", "ed25519-dalek",
"elliptic-curve", "elliptic-curve",
"enum_dispatch", "enum_dispatch",
"flate2",
"futures", "futures",
"generic-array", "generic-array 1.3.5",
"getrandom 0.2.17", "getrandom 0.2.17",
"hex-literal", "hex-literal",
"hmac", "hmac",
"home", "home",
"inout 0.1.4", "inout 0.1.4",
"internal-russh-forked-ssh-key", "internal-russh-forked-ssh-key",
"libcrux-ml-kem",
"log", "log",
"md5", "md5",
"num-bigint", "num-bigint",
"once_cell",
"p256", "p256",
"p384", "p384",
"p521", "p521",
"pageant", "pageant",
"pbkdf2", "pbkdf2",
"pkcs1",
"pkcs5", "pkcs5",
"pkcs8", "pkcs8",
"poly1305 0.8.0",
"rand 0.8.5", "rand 0.8.5",
"rand_core 0.6.4", "rand_core 0.6.4",
"rsa", "ring",
"russh-cryptovec", "russh-cryptovec",
"russh-util", "russh-util",
"sec1 0.7.3", "sec1 0.7.3",
@ -6029,7 +6153,6 @@ dependencies = [
"subtle", "subtle",
"thiserror 1.0.69", "thiserror 1.0.69",
"tokio", "tokio",
"tokio-stream",
"typenum", "typenum",
"yasna", "yasna",
"zeroize", "zeroize",
@ -6037,21 +6160,22 @@ dependencies = [
[[package]] [[package]]
name = "russh-cryptovec" name = "russh-cryptovec"
version = "0.50.2" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1fcb7c127135848b47715b5bcb13d8a27ccd86ce1de1c15eab5982df91fe279a" checksum = "4fb0ed583ff0f6b4aa44c7867dd7108df01b30571ee9423e250b4cc939f8c6cf"
dependencies = [ dependencies = [
"libc", "libc",
"log", "log",
"nix",
"ssh-encoding 0.2.0", "ssh-encoding 0.2.0",
"winapi", "winapi",
] ]
[[package]] [[package]]
name = "russh-util" name = "russh-util"
version = "0.50.0" version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c698d702527b51a82e64de98d506e9c1e83a063035d41df4f2354499ec090b79" checksum = "668424a5dde0bcb45b55ba7de8476b93831b4aa2fa6947e145f3b053e22c60b6"
dependencies = [ dependencies = [
"chrono", "chrono",
"tokio", "tokio",
@ -6470,7 +6594,7 @@ checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [ dependencies = [
"base16ct 0.2.0", "base16ct 0.2.0",
"der", "der",
"generic-array", "generic-array 0.14.7",
"pkcs8", "pkcs8",
"subtle", "subtle",
"zeroize", "zeroize",
@ -6993,7 +7117,7 @@ dependencies = [
"futures-core", "futures-core",
"futures-io", "futures-io",
"futures-util", "futures-util",
"generic-array", "generic-array 0.14.7",
"hex", "hex",
"hkdf", "hkdf",
"hmac", "hmac",
@ -7278,7 +7402,7 @@ dependencies = [
"ntapi", "ntapi",
"objc2-core-foundation", "objc2-core-foundation",
"objc2-io-kit", "objc2-io-kit",
"windows 0.62.2", "windows",
] ]
[[package]] [[package]]
@ -7461,6 +7585,27 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
[[package]]
name = "tls_codec"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de2e01245e2bb89d6f05801c564fa27624dbd7b1846859876c7dad82e90bf6b"
dependencies = [
"tls_codec_derive",
"zeroize",
]
[[package]]
name = "tls_codec_derive"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2d2e76690929402faae40aebdda620a2c0e25dd6d3b9afe48867dfd95991f4bd"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "tokio" name = "tokio"
version = "1.50.0" version = "1.50.0"
@ -8184,16 +8329,6 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f"
[[package]]
name = "windows"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dd04d41d93c4992d421894c18c8b43496aa748dd4c081bac0dc93eb0489272b6"
dependencies = [
"windows-core 0.58.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows" name = "windows"
version = "0.62.2" version = "0.62.2"
@ -8201,7 +8336,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580" checksum = "527fadee13e0c05939a6a05d5bd6eec6cd2e3dbd648b9f8e447c6518133d8580"
dependencies = [ dependencies = [
"windows-collections", "windows-collections",
"windows-core 0.62.2", "windows-core",
"windows-future", "windows-future",
"windows-numerics", "windows-numerics",
] ]
@ -8212,20 +8347,7 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610" checksum = "23b2d95af1a8a14a3c7367e1ed4fc9c20e0a26e79551b1454d72583c97cc6610"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
]
[[package]]
name = "windows-core"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ba6d44ec8c2591c134257ce647b7ea6b20335bf6379a27dac5f1641fcf59f99"
dependencies = [
"windows-implement 0.58.0",
"windows-interface 0.58.0",
"windows-result 0.2.0",
"windows-strings 0.1.0",
"windows-targets 0.52.6",
] ]
[[package]] [[package]]
@ -8234,11 +8356,11 @@ version = "0.62.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
dependencies = [ dependencies = [
"windows-implement 0.60.2", "windows-implement",
"windows-interface 0.59.3", "windows-interface",
"windows-link", "windows-link",
"windows-result 0.4.1", "windows-result",
"windows-strings 0.5.1", "windows-strings",
] ]
[[package]] [[package]]
@ -8247,22 +8369,11 @@ version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb" checksum = "e1d6f90251fe18a279739e78025bd6ddc52a7e22f921070ccdc67dde84c605cb"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
"windows-link", "windows-link",
"windows-threading", "windows-threading",
] ]
[[package]]
name = "windows-implement"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2bbd5b46c938e506ecbce286b6628a02171d56153ba733b6c741fc627ec9579b"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "windows-implement" name = "windows-implement"
version = "0.60.2" version = "0.60.2"
@ -8274,17 +8385,6 @@ dependencies = [
"syn 2.0.117", "syn 2.0.117",
] ]
[[package]]
name = "windows-interface"
version = "0.58.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "053c4c462dc91d3b1504c6fe5a726dd15e216ba718e84a0e46a88fbe5ded3515"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "windows-interface" name = "windows-interface"
version = "0.59.3" version = "0.59.3"
@ -8308,19 +8408,10 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26" checksum = "6e2e40844ac143cdb44aead537bbf727de9b044e107a0f1220392177d15b0f26"
dependencies = [ dependencies = [
"windows-core 0.62.2", "windows-core",
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-result"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e"
dependencies = [
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-result" name = "windows-result"
version = "0.4.1" version = "0.4.1"
@ -8330,16 +8421,6 @@ dependencies = [
"windows-link", "windows-link",
] ]
[[package]]
name = "windows-strings"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10"
dependencies = [
"windows-result 0.2.0",
"windows-targets 0.52.6",
]
[[package]] [[package]]
name = "windows-strings" name = "windows-strings"
version = "0.5.1" version = "0.5.1"
@ -8799,6 +8880,20 @@ name = "zeroize"
version = "1.8.2" version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
dependencies = [
"zeroize_derive",
]
[[package]]
name = "zeroize_derive"
version = "1.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]] [[package]]
name = "zerotrie" name = "zerotrie"

View File

@ -98,7 +98,7 @@ prost = "0.14.3"
prost-build = "0.14.3" prost-build = "0.14.3"
qdrant-client = "1.17.0" qdrant-client = "1.17.0"
rand = "0.10.0" rand = "0.10.0"
russh = { version = "0.50.0", default-features = false, features = [] } russh = { version = "0.55.0", default-features = false }
hmac = { version = "0.12.1", features = ["std"] } hmac = { version = "0.12.1", features = ["std"] }
sha1_smol = "1.0.1" sha1_smol = "1.0.1"
rsa = { version = "0.9.7", package = "rsa" } rsa = { version = "0.9.7", package = "rsa" }

View File

@ -379,12 +379,10 @@ async fn poll_push_streams(streams: &mut PushStreams) -> Option<WsPushEvent> {
} }
return Some(WsPushEvent::RoomMessage { room_id, event }); return Some(WsPushEvent::RoomMessage { room_id, event });
} }
Some(Err(_)) | None => { Some(Err(_)) => {
// Stream closed/error — remove and re-subscribe to avoid streams.remove(&room_id);
// spinning on a closed stream. The manager keeps the }
// broadcast sender alive so re-subscribing gets the latest None => {
// receiver. Multiple rapid errors are handled by the
// manager's existing retry/cleanup logic.
streams.remove(&room_id); streams.remove(&room_id);
} }
} }

View File

@ -39,7 +39,7 @@ chrono = { workspace = true }
sysinfo = { workspace = true } sysinfo = { workspace = true }
num_cpus = { workspace = true } num_cpus = { workspace = true }
futures = { workspace = true } futures = { workspace = true }
russh = { workspace = true, features = ["legacy-ed25519-pkcs8-parser"] } russh = { workspace = true, features = ["flate2", "ring", "legacy-ed25519-pkcs8-parser"] }
anyhow = { workspace = true } anyhow = { workspace = true }
base64 = { workspace = true } base64 = { workspace = true }
sha1 = { workspace = true } sha1 = { workspace = true }

View File

@ -48,31 +48,26 @@ impl GitHttpHandler {
"git-upload-pack" => "upload-pack", "git-upload-pack" => "upload-pack",
"git-receive-pack" => "receive-pack", "git-receive-pack" => "receive-pack",
_ => { _ => {
return Err(actix_web::error::ErrorBadRequest("Invalid service")); return Ok(HttpResponse::BadRequest().body("Invalid service"));
} }
}; };
let output = tokio::time::timeout(GIT_OPERATION_TIMEOUT, async { let output = tokio::process::Command::new("git")
tokio::process::Command::new("git") .arg(git_cmd)
.arg(git_cmd) .arg("--stateless-rpc")
.arg("--stateless-rpc") .arg("--advertise-refs")
.arg("--advertise-refs") .arg(&self.storage_path)
.arg(&self.storage_path) .output()
.output() .await
.await .map_err(|e| {
}) actix_web::error::ErrorInternalServerError(format!("Failed to execute git: {}", e))
.await })?;
.map_err(|_| actix_web::error::ErrorInternalServerError("Git info-refs timeout"))?
.map_err(|e| {
actix_web::error::ErrorInternalServerError(format!("Failed to execute git: {}", e))
})?;
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(actix_web::error::ErrorInternalServerError(format!( return Ok(
"Git command failed: {}", HttpResponse::InternalServerError().body(format!("Git command failed: {}", stderr))
stderr );
)));
} }
let mut response_body = Vec::new(); let mut response_body = Vec::new();
@ -133,17 +128,21 @@ impl GitHttpHandler {
// Reject oversized pre-PACK data to prevent memory exhaustion // Reject oversized pre-PACK data to prevent memory exhaustion
if pre_pack.len() + bytes.len() > PRE_PACK_LIMIT { if pre_pack.len() + bytes.len() > PRE_PACK_LIMIT {
return Err(actix_web::error::ErrorPayloadTooLarge(format!( return Ok(HttpResponse::PayloadTooLarge()
"Ref negotiation exceeds {} byte limit", .insert_header(("Content-Type", "text/plain"))
PRE_PACK_LIMIT .body(format!(
))); "Ref negotiation exceeds {} byte limit",
PRE_PACK_LIMIT
)));
} }
if let Some(pos) = bytes.windows(4).position(|w| w == PACK_SIG) { if let Some(pos) = bytes.windows(4).position(|w| w == PACK_SIG) {
pre_pack.extend_from_slice(&bytes[..pos]); pre_pack.extend_from_slice(&bytes[..pos]);
if let Err(msg) = check_branch_protection(&branch_protects, &pre_pack) { if let Err(msg) = check_branch_protection(&branch_protects, &pre_pack) {
return Err(actix_web::error::ErrorForbidden(msg)); return Ok(HttpResponse::Forbidden()
.insert_header(("Content-Type", "text/plain"))
.body(msg));
} }
let remaining: ByteStream = Box::pin(stream! { let remaining: ByteStream = Box::pin(stream! {
@ -202,10 +201,9 @@ impl GitHttpHandler {
if !output.status.success() { if !output.status.success() {
let stderr = String::from_utf8_lossy(&output.stderr); let stderr = String::from_utf8_lossy(&output.stderr);
return Err(actix_web::error::ErrorInternalServerError(format!( return Ok(HttpResponse::InternalServerError()
"Git command failed: {}", .insert_header(("Content-Type", "text/plain"))
stderr .body(format!("Git command failed: {}", stderr)));
)));
} }
Ok(HttpResponse::Ok() Ok(HttpResponse::Ok()

View File

@ -12,8 +12,6 @@ use std::collections::HashMap;
use std::path::PathBuf; use std::path::PathBuf;
const LFS_AUTH_TOKEN_EXPIRY: u64 = 3600; const LFS_AUTH_TOKEN_EXPIRY: u64 = 3600;
/// Maximum LFS object size in bytes (50 GiB, matching GitHub/Gitea default).
const LFS_MAX_OBJECT_SIZE: i64 = 50 * 1024 * 1024 * 1024;
#[derive(Deserialize, Serialize)] #[derive(Deserialize, Serialize)]
pub struct BatchRequest { pub struct BatchRequest {
@ -130,15 +128,6 @@ impl LfsHandler {
))); )));
} }
for obj in &req.objects {
if obj.size > LFS_MAX_OBJECT_SIZE {
return Err(GitError::InvalidOid(format!(
"Object size {} exceeds maximum allowed size {}",
obj.size, LFS_MAX_OBJECT_SIZE
)));
}
}
let oids: Vec<&str> = req.objects.iter().map(|o| o.oid.as_str()).collect(); let oids: Vec<&str> = req.objects.iter().map(|o| o.oid.as_str()).collect();
// Single batch query for all OIDs // Single batch query for all OIDs
@ -271,15 +260,6 @@ impl LfsHandler {
while let Some(chunk) = payload.next().await { while let Some(chunk) = payload.next().await {
let chunk = chunk.map_err(|e| GitError::Internal(format!("Payload error: {}", e)))?; let chunk = chunk.map_err(|e| GitError::Internal(format!("Payload error: {}", e)))?;
size += chunk.len() as i64; size += chunk.len() as i64;
// Hard limit: abort if we exceed the max LFS object size.
// This prevents unbounded disk usage from a malicious or misbehaving client.
if size > LFS_MAX_OBJECT_SIZE {
let _ = tokio::fs::remove_file(&temp_path).await;
return Err(GitError::InvalidOid(format!(
"Object size exceeds maximum allowed size {}",
LFS_MAX_OBJECT_SIZE
)));
}
hasher.update(&chunk); hasher.update(&chunk);
if let Err(e) = file.write_all(&chunk).await { if let Err(e) = file.write_all(&chunk).await {
let _ = tokio::fs::remove_file(&temp_path).await; let _ = tokio::fs::remove_file(&temp_path).await;

View File

@ -4,6 +4,8 @@ use db::cache::AppCache;
use db::database::AppDatabase; use db::database::AppDatabase;
use slog::{Logger, error, info}; use slog::{Logger, error, info};
use std::sync::Arc; use std::sync::Arc;
use std::time::Duration;
use tokio::time::timeout;
pub mod auth; pub mod auth;
pub mod handler; pub mod handler;
@ -97,12 +99,27 @@ pub async fn run_http(config: AppConfig, logger: Logger) -> anyhow::Result<()> {
.bind("0.0.0.0:8021")? .bind("0.0.0.0:8021")?
.run(); .run();
// Await the server. Actix-web handles Ctrl+C gracefully by default: let (shutdown_tx, shutdown_rx) = tokio::sync::oneshot::channel::<()>();
// workers finish in-flight requests then exit (graceful shutdown). let server = server;
let result = server.await; let logger_shutdown = logger.clone();
if let Err(e) = result { let server_handle = tokio::spawn(async move {
error!(&logger, "HTTP server error: {}", e); tokio::select! {
} result = server => {
if let Err(e) = result {
error!(&logger_shutdown, "HTTP server error: {}", e);
}
}
_ = shutdown_rx => {
info!(&logger_shutdown, "HTTP server shutting down");
}
}
});
tokio::signal::ctrl_c().await?;
info!(&logger, "Received shutdown signal");
drop(shutdown_tx);
let _ = timeout(Duration::from_secs(5), server_handle).await;
info!(&logger, "Git HTTP server stopped"); info!(&logger, "Git HTTP server stopped");
Ok(()) Ok(())

View File

@ -36,12 +36,6 @@ struct RateLimitBucket {
reset_time: Instant, reset_time: Instant,
} }
#[derive(Clone, Copy)]
enum BucketOp {
Read,
Write,
}
pub struct RateLimiter { pub struct RateLimiter {
buckets: Arc<RwLock<HashMap<String, RateLimitBucket>>>, buckets: Arc<RwLock<HashMap<String, RateLimitBucket>>>,
config: RateLimitConfig, config: RateLimitConfig,
@ -57,23 +51,23 @@ impl RateLimiter {
pub async fn is_ip_read_allowed(&self, ip: &str) -> bool { pub async fn is_ip_read_allowed(&self, ip: &str) -> bool {
let key = format!("ip:read:{}", ip); let key = format!("ip:read:{}", ip);
self.is_allowed(&key, BucketOp::Read, self.config.read_requests_per_window) self.is_allowed(&key, self.config.read_requests_per_window)
.await .await
} }
pub async fn is_ip_write_allowed(&self, ip: &str) -> bool { pub async fn is_ip_write_allowed(&self, ip: &str) -> bool {
let key = format!("ip:write:{}", ip); let key = format!("ip:write:{}", ip);
self.is_allowed(&key, BucketOp::Write, self.config.write_requests_per_window) self.is_allowed(&key, self.config.write_requests_per_window)
.await .await
} }
pub async fn is_repo_write_allowed(&self, ip: &str, repo_path: &str) -> bool { pub async fn is_repo_write_allowed(&self, ip: &str, repo_path: &str) -> bool {
let key = format!("repo:write:{}:{}", ip, repo_path); let key = format!("repo:write:{}:{}", ip, repo_path);
self.is_allowed(&key, BucketOp::Write, self.config.write_requests_per_window) self.is_allowed(&key, self.config.write_requests_per_window)
.await .await
} }
async fn is_allowed(&self, key: &str, op: BucketOp, limit: u32) -> bool { async fn is_allowed(&self, key: &str, limit: u32) -> bool {
let now = Instant::now(); let now = Instant::now();
let mut buckets = self.buckets.write().await; let mut buckets = self.buckets.write().await;
@ -91,19 +85,12 @@ impl RateLimiter {
bucket.reset_time = now + Duration::from_secs(self.config.window_secs); bucket.reset_time = now + Duration::from_secs(self.config.window_secs);
} }
let over_limit = match op { // Use read_count for both read/write since we don't distinguish in bucket
BucketOp::Read => bucket.read_count >= limit, if bucket.read_count >= limit {
BucketOp::Write => bucket.write_count >= limit,
};
if over_limit {
return false; return false;
} }
match op { bucket.read_count += 1;
BucketOp::Read => bucket.read_count += 1,
BucketOp::Write => bucket.write_count += 1,
}
true true
} }

View File

@ -28,7 +28,7 @@ pub async fn info_refs(
.ok_or_else(|| actix_web::error::ErrorBadRequest("Missing service parameter"))?; .ok_or_else(|| actix_web::error::ErrorBadRequest("Missing service parameter"))?;
if service_param != "git-upload-pack" && service_param != "git-receive-pack" { if service_param != "git-upload-pack" && service_param != "git-receive-pack" {
return Err(actix_web::error::ErrorBadRequest("Invalid service")); return Ok(HttpResponse::BadRequest().body("Invalid service"));
} }
let path_inner = path.into_inner(); let path_inner = path.into_inner();

View File

@ -163,12 +163,19 @@ impl russh::server::Handler for SSHandle {
Ok(Auth::UnsupportedMethod) Ok(Auth::UnsupportedMethod)
} }
async fn auth_password(&mut self, _user: &str, token: &str) -> Result<Auth, Self::Error> { async fn auth_password(&mut self, user: &str, token: &str) -> Result<Auth, Self::Error> {
let client_info = self let client_info = self
.client_addr .client_addr
.map(|addr| format!("{}", addr)) .map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string()); .unwrap_or_else(|| "unknown".to_string());
if user != "git" {
warn!(
self.logger,
"auth_password rejected: invalid username '{}', client: {}", user, client_info
);
return Err(russh::Error::NotAuthenticated);
}
if token.is_empty() { if token.is_empty() {
warn!( warn!(
@ -416,54 +423,11 @@ impl russh::server::Handler for SSHandle {
async fn channel_open_session( async fn channel_open_session(
&mut self, &mut self,
channel: Channel<Msg>, _: Channel<Msg>,
session: &mut Session, _: &mut Session,
) -> Result<bool, Self::Error> { ) -> Result<bool, Self::Error> {
let client_info = self
.client_addr
.map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string());
info!(self.logger, "{}", format!("channel_open_session channel={:?} client={}", channel, client_info));
let _ = session.flush().ok();
Ok(true) Ok(true)
} }
async fn pty_request(
&mut self,
channel: ChannelId,
term: &str,
col_width: u32,
row_height: u32,
_pix_width: u32,
_pix_height: u32,
_modes: &[(russh::Pty, u32)],
session: &mut Session,
) -> Result<(), Self::Error> {
let client_info = self
.client_addr
.map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string());
warn!(self.logger, "{}", format!("pty_request not supported channel={:?} term={} cols={} rows={} client={}", channel, term, col_width, row_height, client_info));
let _ = session.flush().ok();
Ok(())
}
async fn subsystem_request(
&mut self,
channel: ChannelId,
name: &str,
session: &mut Session,
) -> Result<(), Self::Error> {
let client_info = self
.client_addr
.map(|addr| format!("{}", addr))
.unwrap_or_else(|| "unknown".to_string());
info!(self.logger, "{}", format!("subsystem_request channel={:?} subsystem={} client={}", channel, name, client_info));
// git-clients may send "subsystem" for git protocol over ssh.
// We don't use subsystem; exec_request handles it directly.
let _ = session.flush().ok();
Ok(())
}
async fn data( async fn data(
&mut self, &mut self,
channel: ChannelId, channel: ChannelId,
@ -847,45 +811,20 @@ fn parse_repo_path(path: &str) -> Option<(&str, &str)> {
fn build_git_command(service: GitService, path: PathBuf) -> tokio::process::Command { fn build_git_command(service: GitService, path: PathBuf) -> tokio::process::Command {
let mut cmd = tokio::process::Command::new("git"); let mut cmd = tokio::process::Command::new("git");
// Canonicalize only for validation; if it fails, fall back to the raw path. let canonical_path = path.canonicalize().unwrap_or(path);
// Using canonicalize for current_dir is safe since we validate repo existence cmd.current_dir(canonical_path);
// before reaching this point.
let cwd = match path.canonicalize() {
Ok(p) => p,
Err(e) => {
// Log and continue with the raw path — the git process will fail
// with a clear "repository not found" message rather than panicking here.
let _ = e;
path.clone()
}
};
cmd.current_dir(cwd);
match service { match service {
GitService::UploadPack => { cmd.arg("upload-pack"); } GitService::UploadPack => cmd.arg("upload-pack"),
GitService::ReceivePack => { cmd.arg("receive-pack"); } GitService::ReceivePack => cmd.arg("receive-pack"),
GitService::UploadArchive => { cmd.arg("upload-archive"); } GitService::UploadArchive => cmd.arg("upload-archive"),
} };
cmd.arg(".") cmd.arg(".")
.env("GIT_CONFIG_NOSYSTEM", "1") .env("GIT_CONFIG_NOSYSTEM", "1")
.env("GIT_NO_REPLACE_OBJECTS", "1"); .env("GIT_NO_REPLACE_OBJECTS", "1")
.env("GIT_CONFIG_GLOBAL", "/dev/null")
#[cfg(unix)] .env("GIT_CONFIG_SYSTEM", "/dev/null");
{
cmd.env("GIT_CONFIG_GLOBAL", "/dev/null")
.env("GIT_CONFIG_SYSTEM", "/dev/null");
}
#[cfg(windows)]
{
// On Windows, /dev/null doesn't exist. Set invalid paths so git
// ignores them without crashing. GIT_CONFIG_NOSYSTEM already disables
// the system config.
let nul = "NUL";
cmd.env("GIT_CONFIG_GLOBAL", nul)
.env("GIT_CONFIG_SYSTEM", nul);
}
cmd cmd
} }
@ -925,8 +864,6 @@ where
Fwd: FnMut(&'a Handle, ChannelId, CryptoVec) -> Fut, Fwd: FnMut(&'a Handle, ChannelId, CryptoVec) -> Fut,
{ {
const BUF_SIZE: usize = 1024 * 32; const BUF_SIZE: usize = 1024 * 32;
const MAX_RETRIES: usize = 5;
const RETRY_DELAY: u64 = 10; // ms
let mut buf = [0u8; BUF_SIZE]; let mut buf = [0u8; BUF_SIZE];
loop { loop {
@ -937,20 +874,12 @@ where
} }
let mut chunk = CryptoVec::from_slice(&buf[..read]); let mut chunk = CryptoVec::from_slice(&buf[..read]);
let mut retries = 0;
loop { loop {
match fwd(session_handle, chan_id, chunk).await { match fwd(session_handle, chan_id, chunk).await {
Ok(()) => break, Ok(()) => break,
Err(unsent) => { Err(unsent) => {
retries += 1;
if retries >= MAX_RETRIES {
// Give up — connection is likely broken. Returning Ok (not Err)
// so the outer task can clean up gracefully without logging
// a spurious error for a normal disconnection.
return Ok(());
}
chunk = unsent; chunk = unsent;
sleep(Duration::from_millis(RETRY_DELAY)).await; sleep(Duration::from_millis(5)).await;
} }
} }
} }

View File

@ -145,10 +145,6 @@ impl SSHHandle {
self.logger.clone(), self.logger.clone(),
token_service, token_service,
); );
// Start the rate limiter cleanup background task so the HashMap
// doesn't grow unbounded over time.
let _cleanup = server.rate_limiter.clone().start_cleanup();
let ssh_port = self.app.ssh_port()?; let ssh_port = self.app.ssh_port()?;
let bind_addr = format!("0.0.0.0:{}", ssh_port); let bind_addr = format!("0.0.0.0:{}", ssh_port);
let public_host = self.app.ssh_domain()?; let public_host = self.app.ssh_domain()?;

View File

@ -25,7 +25,6 @@ struct RateLimitState {
reset_time: Instant, reset_time: Instant,
} }
#[derive(Debug, Clone)]
pub struct RateLimiter { pub struct RateLimiter {
limits: Arc<RwLock<HashMap<String, RateLimitState>>>, limits: Arc<RwLock<HashMap<String, RateLimitState>>>,
config: RateLimitConfig, config: RateLimitConfig,
@ -132,10 +131,4 @@ impl SshRateLimiter {
.is_allowed(&format!("repo_access:{}:{}", user_id, repo_path)) .is_allowed(&format!("repo_access:{}:{}", user_id, repo_path))
.await .await
} }
/// Start a background cleanup task that removes expired entries every 5 minutes.
/// Prevents unbounded HashMap growth in the underlying RateLimiter.
pub fn start_cleanup(self: Arc<Self>) -> tokio::task::JoinHandle<()> {
RateLimiter::start_cleanup(Arc::new(self.limiter.clone()))
}
} }

View File

@ -16,7 +16,7 @@ use crate::error::RoomError;
use crate::metrics::RoomMetrics; use crate::metrics::RoomMetrics;
use crate::types::NotificationEvent; use crate::types::NotificationEvent;
const BROADCAST_CAPACITY: usize = 100_000; const BROADCAST_CAPACITY: usize = 10000;
const SHUTDOWN_CHANNEL_CAPACITY: usize = 16; const SHUTDOWN_CHANNEL_CAPACITY: usize = 16;
const CONNECTION_COOLDOWN: Duration = Duration::from_secs(30); const CONNECTION_COOLDOWN: Duration = Duration::from_secs(30);
const MAX_CONNECTIONS_PER_ROOM: usize = 50000; const MAX_CONNECTIONS_PER_ROOM: usize = 50000;
@ -185,13 +185,6 @@ impl RoomConnectionManager {
} }
} }
{
let mut stream_map = self.room_stream_inner.write().await;
for room_id in &idle_room_ids {
stream_map.remove(room_id);
}
}
{ {
let mut txs = self.room_shutdown_txs.write().await; let mut txs = self.room_shutdown_txs.write().await;
for room_id in &idle_room_ids { for room_id in &idle_room_ids {
@ -243,10 +236,6 @@ impl RoomConnectionManager {
let mut map = self.room_inner.write().await; let mut map = self.room_inner.write().await;
map.remove(&room_id); map.remove(&room_id);
} }
{
let mut stream_map = self.room_stream_inner.write().await;
stream_map.remove(&room_id);
}
{ {
let mut txs = self.room_shutdown_txs.write().await; let mut txs = self.room_shutdown_txs.write().await;
txs.remove(&room_id); txs.remove(&room_id);

View File

@ -1121,46 +1121,25 @@ impl RoomService {
let mut conn = cache.conn().await.map_err(|e| { let mut conn = cache.conn().await.map_err(|e| {
RoomError::Internal(format!("failed to get redis connection for seq: {}", e)) RoomError::Internal(format!("failed to get redis connection for seq: {}", e))
})?; })?;
// Atomically increment and check via Lua: INCR first, then if Redis was let seq: i64 = redis::cmd("INCR")
// externally set to a higher value, jump to max+1. This prevents concurrent
// requests from getting duplicate seqs — the Lua script runs as one atomic unit.
let seq: i64 = redis::cmd("EVAL")
.arg(
r#"
local current = redis.call('INCR', KEYS[1])
local stored = redis.call('GET', KEYS[1])
if stored and tonumber(stored) > current then
local next = tonumber(stored) + 1
redis.call('SET', KEYS[1], next)
return next
end
return current
"#,
)
.arg(1)
.arg(&seq_key) .arg(&seq_key)
.query_async(&mut conn) .query_async(&mut conn)
.await .await
.map_err(|e| RoomError::Internal(format!("seq Lua script: {}", e)))?; .map_err(|e| RoomError::Internal(format!("INCR seq: {}", e)))?;
// Reconciliation check: if DB is ahead of Redis (e.g. server restart wiped
// Redis), bump Redis to stay in sync. This query is only hit on the rare
// cross-server handoff case, not on every request.
use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage}; use models::rooms::room_message::{Column as RmCol, Entity as RoomMessage};
use sea_orm::EntityTrait; use sea_orm::EntityTrait;
let db_seq: Option<Option<Option<i64>>> = RoomMessage::find() let db_seq: Option<Option<i64>> = RoomMessage::find()
.filter(RmCol::Room.eq(room_id)) .filter(RmCol::Room.eq(room_id))
.select_only() .select_only()
.column_as(RmCol::Seq.max(), "max_seq") .column_as(RmCol::Seq.max(), "max_seq")
.into_tuple::<Option<Option<i64>>>() .into_tuple::<Option<i64>>()
.one(db) .one(db)
.await? .await?
.map(|r| r); .map(|r| r);
let db_seq = db_seq.flatten().flatten().unwrap_or(0); let db_seq = db_seq.flatten().unwrap_or(0);
if db_seq >= seq { if db_seq >= seq {
// Another server handled this room while we were idle — catch up. let _: i64 = redis::cmd("SET")
let _: String = redis::cmd("SET")
.arg(&seq_key) .arg(&seq_key)
.arg(db_seq + 1) .arg(db_seq + 1)
.query_async(&mut conn) .query_async(&mut conn)

View File

@ -277,13 +277,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
? (wsError ?? 'Connection error') ? (wsError ?? 'Connection error')
: null; : null;
// Visual connection status dot (Discord-style)
const statusDotColor = wsStatus === 'open'
? 'bg-green-500'
: wsStatus === 'connecting'
? 'bg-yellow-400 animate-pulse'
: 'bg-red-500';
const handleSend = useCallback( const handleSend = useCallback(
(content: string) => { (content: string) => {
sendMessage(content, 'text', replyingTo?.id ?? undefined); sendMessage(content, 'text', replyingTo?.id ?? undefined);
@ -387,10 +380,6 @@ export function RoomChatPanel({ room, isAdmin, onClose, onDelete }: RoomChatPane
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Hash className="h-5 w-5 text-muted-foreground" /> <Hash className="h-5 w-5 text-muted-foreground" />
<h1 className="truncate text-base font-semibold text-foreground">{room.room_name}</h1> <h1 className="truncate text-base font-semibold text-foreground">{room.room_name}</h1>
<span
className={cn('h-2 w-2 rounded-full', statusDotColor)}
title={connectionLabel ?? 'Connected'}
/>
{!room.public && ( {!room.public && (
<span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground"> <span className="rounded bg-muted px-1.5 py-0.5 text-[11px] text-muted-foreground">
Private Private

View File

@ -98,9 +98,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
const isOwner = user?.uid === getSenderUserUid(message); const isOwner = user?.uid === getSenderUserUid(message);
const isRevoked = !!message.revoked; const isRevoked = !!message.revoked;
const isFailed = message.isOptimisticError === true; const isFailed = message.isOptimisticError === true;
// True for messages that haven't been confirmed by the server yet. const isPending = message.id.startsWith('temp-');
// Handles both the old 'temp-' prefix and the new isOptimistic flag.
const isPending = message.isOptimistic === true || message.id.startsWith('temp-') || message.id.startsWith('optimistic-');
// Get streaming content if available // Get streaming content if available
const displayContent = isStreaming && streamingMessages?.has(message.id) const displayContent = isStreaming && streamingMessages?.has(message.id)
@ -215,7 +213,6 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
grouped ? 'py-0.5' : 'py-2', grouped ? 'py-0.5' : 'py-2',
!isSystem && 'hover:bg-muted/30', !isSystem && 'hover:bg-muted/30',
isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5', isSystem && 'border-l-2 border-amber-500/60 bg-amber-500/5',
(isPending || isFailed) && 'opacity-60',
)} )}
> >
{/* Avatar */} {/* Avatar */}
@ -417,7 +414,7 @@ export const RoomMessageBubble = memo(function RoomMessageBubble({
)} )}
{/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */} {/* Action toolbar - inline icons when wide, collapsed to dropdown when narrow */}
{!isEditing && !isRevoked && !isPending && ( {!isEditing && !isRevoked && (
<div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100"> <div className="flex items-start gap-0.5 opacity-0 transition-opacity group-hover:opacity-100">
{isNarrow ? ( {isNarrow ? (
/* Narrow: all actions in dropdown */ /* Narrow: all actions in dropdown */

View File

@ -60,8 +60,6 @@ export type MessageWithMeta = RoomMessageResponse & {
display_content?: string; display_content?: string;
is_streaming?: boolean; is_streaming?: boolean;
isOptimisticError?: boolean; isOptimisticError?: boolean;
/** True for messages sent by the current user that haven't been confirmed by the server */
isOptimistic?: boolean;
reactions?: ReactionGroup[]; reactions?: ReactionGroup[];
}; };
@ -248,11 +246,7 @@ export function RoomProvider({
if (client.getStatus() !== 'open') { if (client.getStatus() !== 'open') {
await client.connect(); await client.connect();
} }
// Re-check: activeRoomId may have changed while we were waiting for connect. await client.subscribeRoom(activeRoomId);
// Use activeRoomIdRef to get the *current* room, not the stale closure value.
const roomId = activeRoomIdRef.current;
if (!roomId) return;
await client.subscribeRoom(roomId);
loadMoreRef.current?.(null); loadMoreRef.current?.(null);
}; };
setup(); setup();
@ -399,27 +393,9 @@ export function RoomProvider({
// Use ref to get current activeRoomId to avoid stale closure // Use ref to get current activeRoomId to avoid stale closure
if (payload.room_id === activeRoomIdRef.current) { if (payload.room_id === activeRoomIdRef.current) {
setMessages((prev) => { setMessages((prev) => {
// Deduplicate by both ID (for normal) and seq (for optimistic replacement)
if (prev.some((m) => m.id === payload.id)) { if (prev.some((m) => m.id === payload.id)) {
return prev; return prev;
} }
// Also check if there's an optimistic message with the same seq that should be replaced
const optimisticIdx = prev.findIndex(
(m) => m.isOptimistic && m.seq === payload.seq && m.seq !== 0,
);
if (optimisticIdx !== -1) {
// Replace optimistic message with confirmed one
const confirmed: MessageWithMeta = {
...wsMessageToUiMessage(payload),
reactions: prev[optimisticIdx].reactions,
};
const next = [...prev];
next[optimisticIdx] = confirmed;
// Remove optimistic from IDB, save confirmed
deleteMessageFromIdb(prev[optimisticIdx].id).catch(() => {});
saveMessage(confirmed).catch(() => {});
return next;
}
const newMsg = wsMessageToUiMessage(payload); const newMsg = wsMessageToUiMessage(payload);
let updated = [...prev, newMsg]; let updated = [...prev, newMsg];
updated.sort((a, b) => a.seq - b.seq); updated.sort((a, b) => a.seq - b.seq);
@ -500,16 +476,13 @@ export function RoomProvider({
const client = wsClientRef.current; const client = wsClientRef.current;
if (!client) return; if (!client) return;
// Capture original edited_at for rollback if fetch fails // Optimistic update: set edited_at immediately
let rollbackEditedAt: string | null = null;
setMessages((prev) => { setMessages((prev) => {
const msg = prev.find((m) => m.id === payload.message_id);
rollbackEditedAt = msg?.edited_at ?? null;
const updated = prev.map((m) => const updated = prev.map((m) =>
m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m, m.id === payload.message_id ? { ...m, edited_at: payload.edited_at } : m,
); );
const saved = updated.find((m) => m.id === payload.message_id); const msg = updated.find((m) => m.id === payload.message_id);
if (saved) saveMessage(saved).catch(() => {}); if (msg) saveMessage(msg).catch(() => {});
return updated; return updated;
}); });
@ -529,19 +502,12 @@ export function RoomProvider({
: m, : m,
); );
// Persist to IndexedDB // Persist to IndexedDB
const saved = merged.find((m) => m.id === payload.message_id); const msg = merged.find((m) => m.id === payload.message_id);
if (saved) saveMessage(saved).catch(() => {}); if (msg) saveMessage(msg).catch(() => {});
return merged; return merged;
}); });
} catch { } catch {
// Revert edited_at if the fetch failed // Silently ignore - the optimistic update already applied
if (rollbackEditedAt !== null) {
setMessages((prev) =>
prev.map((m) =>
m.id === payload.message_id ? { ...m, edited_at: rollbackEditedAt! } : m,
),
);
}
} }
}, },
onMessageRevoked: async (payload) => { onMessageRevoked: async (payload) => {
@ -609,17 +575,11 @@ export function RoomProvider({
}, [wsToken]); }, [wsToken]);
useEffect(() => { useEffect(() => {
// NOTE: intentionally omitted [wsClient] from deps. if (!wsClientRef.current) return;
// In React StrictMode the component mounts twice — if wsClient were a dep, wsClientRef.current.connect().catch((e) => {
// the first mount's effect would connect client-1, then StrictMode cleanup
// would disconnect it, then the second mount's effect would connect client-2,
// then immediately the first mount's *second* cleanup would fire and
// disconnect client-2 — leaving WS unconnected. Using a ref for the initial
// connect avoids this. The client is always ready by the time this runs.
wsClientRef.current?.connect().catch((e) => {
console.error('[RoomContext] WS connect error:', e); console.error('[RoomContext] WS connect error:', e);
}); });
}, []); }, [wsClient]);
const connectWs = useCallback(async () => { const connectWs = useCallback(async () => {
const client = wsClientRef.current; const client = wsClientRef.current;
@ -804,113 +764,32 @@ export function RoomProvider({
[activeRoomId], [activeRoomId],
); );
// Guard against double-sending while a previous send is in-flight.
// Without this, rapid clicking can queue multiple optimistic messages and
// create duplicate sends on the server.
const sendingRef = useRef(false);
const sendMessage = useCallback( const sendMessage = useCallback(
async (content: string, contentType = 'text', inReplyTo?: string) => { async (content: string, contentType = 'text', inReplyTo?: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!activeRoomId || !client) return; if (!activeRoomId || !client) return;
if (sendingRef.current) return; await client.messageCreate(activeRoomId, content, {
sendingRef.current = true; contentType,
inReplyTo,
// Optimistic update: add message immediately so user sees it instantly });
const optimisticId = `optimistic-${crypto.randomUUID()}`;
const optimisticMsg: MessageWithMeta = {
id: optimisticId,
room: activeRoomId,
seq: 0,
sender_type: 'member',
sender_id: user?.uid ?? null,
content,
content_type: contentType,
send_at: new Date().toISOString(),
display_content: content,
is_streaming: false,
isOptimistic: true,
thread: inReplyTo,
thread_id: inReplyTo,
in_reply_to: inReplyTo,
reactions: [],
};
setMessages((prev) => [...prev, optimisticMsg]);
// Persist optimistic message to IndexedDB so it's not lost on refresh
saveMessage(optimisticMsg).catch(() => {});
try {
const confirmedMsg = await client.messageCreate(activeRoomId, content, {
contentType,
inReplyTo,
});
// Replace optimistic message with server-confirmed one
setMessages((prev) => {
const without = prev.filter((m) => m.id !== optimisticId);
const confirmed: MessageWithMeta = {
...confirmedMsg,
thread_id: confirmedMsg.thread,
display_content: confirmedMsg.content,
is_streaming: false,
isOptimistic: false,
reactions: [],
};
// Remove optimistic from IDB
deleteMessageFromIdb(optimisticId).catch(() => {});
// Save confirmed to IDB
saveMessage(confirmed).catch(() => {});
return [...without, confirmed];
});
} catch (err) {
// Mark optimistic message as failed
setMessages((prev) =>
prev.map((m) =>
m.id === optimisticId ? { ...m, isOptimisticError: true } : m,
),
);
handleRoomError('Send message', err);
} finally {
sendingRef.current = false;
}
}, },
[activeRoomId, user], [activeRoomId],
); );
const editMessage = useCallback( const editMessage = useCallback(
async (messageId: string, content: string) => { async (messageId: string, content: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!client) return; if (!client) return;
await client.messageUpdate(messageId, content);
// Capture original content for rollback on server rejection setMessages((prev) =>
let rollbackContent: string | null = null; prev.map((m) => (m.id === messageId ? { ...m, content, display_content: content } : m)),
);
// Persist to IndexedDB
setMessages((prev) => { setMessages((prev) => {
const msg = prev.find((m) => m.id === messageId); const msg = prev.find((m) => m.id === messageId);
rollbackContent = msg?.content ?? null; if (msg) saveMessage({ ...msg, content, display_content: content }).catch(() => {});
return prev.map((m) => return prev;
m.id === messageId ? { ...m, content, display_content: content } : m,
);
}); });
try {
await client.messageUpdate(messageId, content);
// Persist updated content to IndexedDB
setMessages((prev) => {
const msg = prev.find((m) => m.id === messageId);
if (msg) saveMessage(msg).catch(() => {});
return prev;
});
} catch (err) {
// Rollback optimistic update on server rejection
if (rollbackContent !== null) {
setMessages((prev) =>
prev.map((m) =>
m.id === messageId ? { ...m, content: rollbackContent!, display_content: rollbackContent! } : m,
),
);
}
handleRoomError('Edit message', err);
}
}, },
[], [],
); );
@ -919,25 +798,10 @@ export function RoomProvider({
async (messageId: string) => { async (messageId: string) => {
const client = wsClientRef.current; const client = wsClientRef.current;
if (!client) return; if (!client) return;
await client.messageRevoke(messageId);
// Optimistic removal: hide message immediately setMessages((prev) => prev.filter((m) => m.id !== messageId));
let rollbackMsg: MessageWithMeta | null = null; // Persist to IndexedDB
setMessages((prev) => { deleteMessageFromIdb(messageId).catch(() => {});
rollbackMsg = prev.find((m) => m.id === messageId) ?? null;
return prev.filter((m) => m.id !== messageId);
});
try {
await client.messageRevoke(messageId);
deleteMessageFromIdb(messageId).catch(() => {});
} catch (err) {
// Rollback: restore message on server rejection
if (rollbackMsg) {
setMessages((prev) => [...prev, rollbackMsg!]);
saveMessage(rollbackMsg!).catch(() => {});
}
handleRoomError('Delete message', err);
}
}, },
[], [],
); );

View File

@ -916,17 +916,15 @@ export class RoomWsClient {
for (const roomId of this.subscribedRooms) { for (const roomId of this.subscribedRooms) {
try { try {
await this.request('room.subscribe', { room_id: roomId }); await this.request('room.subscribe', { room_id: roomId });
} catch (err) { } catch {
// Resubscribe failure is non-fatal — messages still arrive via REST poll. // ignore
// Log at warn level so operators can observe patterns (e.g. auth expiry).
console.warn(`[RoomWs] resubscribe room failed (will retry on next reconnect): ${roomId}`, err);
} }
} }
for (const projectName of this.subscribedProjects) { for (const projectName of this.subscribedProjects) {
try { try {
await this.request('project.subscribe', { project_name: projectName }); await this.request('project.subscribe', { project_name: projectName });
} catch (err) { } catch {
console.warn(`[RoomWs] resubscribe project failed (will retry on next reconnect): ${projectName}`, err); // ignore
} }
} }
} }
@ -934,13 +932,10 @@ export class RoomWsClient {
private scheduleReconnect(): void { private scheduleReconnect(): void {
if (!this.shouldReconnect) return; if (!this.shouldReconnect) return;
// Exponential backoff with full jitter (uniform random within the backoff window). const delay = Math.min(
// Without jitter, all disconnected clients reconnect at exactly the same time this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt),
// (thundering herd) after a server restart, overwhelming it. this.reconnectMaxDelay,
const baseDelay = this.reconnectBaseDelay * Math.pow(2, this.reconnectAttempt); );
const cappedDelay = Math.min(baseDelay, this.reconnectMaxDelay);
const jitter = Math.random() * cappedDelay;
const delay = Math.floor(jitter);
this.reconnectAttempt++; this.reconnectAttempt++;
this.reconnectTimer = setTimeout(() => { this.reconnectTimer = setTimeout(() => {