Commit bc905bad by DigHuang Committed by GitHub

feat(ide-agent): add filesystem exec and path operations support (#7191)

parent 42b661ca
...@@ -4,9 +4,9 @@ version = 4 ...@@ -4,9 +4,9 @@ version = 4
[[package]] [[package]]
name = "anyhow" name = "anyhow"
version = "1.0.102" version = "1.0.103"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" checksum = "2a4385e2e34eb35d6b3efe798b9eb88096925d87726c0798709bf56d9ed84af3"
[[package]] [[package]]
name = "base64" name = "base64"
...@@ -22,9 +22,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" ...@@ -22,9 +22,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "2.11.1" version = "2.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8"
[[package]] [[package]]
name = "block-buffer" name = "block-buffer"
...@@ -37,9 +37,9 @@ dependencies = [ ...@@ -37,9 +37,9 @@ dependencies = [
[[package]] [[package]]
name = "bytes" name = "bytes"
version = "1.11.1" version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" checksum = "8ae3f5d315924270530207e2a68396c3cc547f6dca3fbdca317cfb1a51edb593"
[[package]] [[package]]
name = "cfg-if" name = "cfg-if"
...@@ -95,12 +95,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -95,12 +95,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2"
[[package]] [[package]]
name = "equivalent"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
[[package]]
name = "errno" name = "errno"
version = "0.3.14" version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -154,12 +148,6 @@ dependencies = [ ...@@ -154,12 +148,6 @@ dependencies = [
] ]
[[package]] [[package]]
name = "foldhash"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
[[package]]
name = "futures-core" name = "futures-core"
version = "0.3.32" version = "0.3.32"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -226,43 +214,20 @@ dependencies = [ ...@@ -226,43 +214,20 @@ dependencies = [
[[package]] [[package]]
name = "getrandom" name = "getrandom"
version = "0.4.2" version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099"
dependencies = [ dependencies = [
"cfg-if", "cfg-if",
"libc", "libc",
"r-efi 6.0.0", "r-efi 6.0.0",
"wasip2",
"wasip3",
] ]
[[package]] [[package]]
name = "hashbrown"
version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
"foldhash",
]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "http" name = "http"
version = "1.4.1" version = "1.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" checksum = "6970f50e31d6fc17d3fa27329444bfa74e196cf62e95052a3f6fee181dba6425"
dependencies = [ dependencies = [
"bytes", "bytes",
"itoa", "itoa",
...@@ -275,30 +240,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -275,30 +240,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87"
[[package]] [[package]]
name = "id-arena"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954"
[[package]]
name = "indexmap"
version = "2.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9"
dependencies = [
"equivalent",
"hashbrown 0.17.1",
"serde",
"serde_core",
]
[[package]]
name = "inotify" name = "inotify"
version = "0.11.2" version = "0.11.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"inotify-sys", "inotify-sys",
"libc", "libc",
] ]
...@@ -334,7 +281,7 @@ version = "1.1.2" ...@@ -334,7 +281,7 @@ version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"libc", "libc",
] ]
...@@ -345,12 +292,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -345,12 +292,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
[[package]] [[package]]
name = "leb128fmt"
version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2"
[[package]]
name = "libc" name = "libc"
version = "0.2.186" version = "0.2.186"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -364,15 +305,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" ...@@ -364,15 +305,15 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53"
[[package]] [[package]]
name = "log" name = "log"
version = "0.4.30" version = "0.4.33"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "616ec5685824bcc94416c6d4a7a446eea774a31efd7062c8480ba6fd06d7a6e5" checksum = "0ceec5bc11778974d1bcb055b18002eba7f4b3518b6a0081b3af5f21666da9ad"
[[package]] [[package]]
name = "memchr" name = "memchr"
version = "2.8.1" version = "2.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" checksum = "88904434abc2901f197fe8cc55f0445e7ded921dba5911dad2e2b39b48e663c4"
[[package]] [[package]]
name = "mio" name = "mio"
...@@ -392,7 +333,7 @@ version = "0.28.0" ...@@ -392,7 +333,7 @@ version = "0.28.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"cfg-if", "cfg-if",
"cfg_aliases", "cfg_aliases",
"libc", "libc",
...@@ -404,7 +345,7 @@ version = "9.0.0-rc.4" ...@@ -404,7 +345,7 @@ version = "9.0.0-rc.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e" checksum = "b44b771d4dd781ef14c84078693e67495da6b47f609f72e8a4da8420a861240e"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"inotify", "inotify",
"kqueue", "kqueue",
"libc", "libc",
...@@ -438,7 +379,7 @@ version = "2.1.0" ...@@ -438,7 +379,7 @@ version = "2.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
...@@ -447,7 +388,7 @@ version = "0.3.2" ...@@ -447,7 +388,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 = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
] ]
[[package]] [[package]]
...@@ -512,16 +453,6 @@ dependencies = [ ...@@ -512,16 +453,6 @@ dependencies = [
] ]
[[package]] [[package]]
name = "prettyplease"
version = "0.2.37"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b"
dependencies = [
"proc-macro2",
"syn",
]
[[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -532,9 +463,9 @@ dependencies = [ ...@@ -532,9 +463,9 @@ dependencies = [
[[package]] [[package]]
name = "quote" name = "quote"
version = "1.0.45" version = "1.0.46"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" checksum = "dfbc457d0c7a0759a614551b11a6409e5951f6c7537be1f1b7682b9ae9230368"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
] ]
...@@ -592,7 +523,7 @@ version = "1.1.4" ...@@ -592,7 +523,7 @@ version = "1.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190"
dependencies = [ dependencies = [
"bitflags 2.11.1", "bitflags 2.13.0",
"errno", "errno",
"libc", "libc",
"linux-raw-sys", "linux-raw-sys",
...@@ -609,12 +540,6 @@ dependencies = [ ...@@ -609,12 +540,6 @@ dependencies = [
] ]
[[package]] [[package]]
name = "semver"
version = "1.0.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd"
[[package]]
name = "serde" name = "serde"
version = "1.0.228" version = "1.0.228"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -696,6 +621,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -696,6 +621,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
[[package]] [[package]]
name = "signal-hook-registry"
version = "1.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b"
dependencies = [
"errno",
"libc",
]
[[package]]
name = "slab" name = "slab"
version = "0.4.12" version = "0.4.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -713,9 +648,9 @@ dependencies = [ ...@@ -713,9 +648,9 @@ dependencies = [
[[package]] [[package]]
name = "syn" name = "syn"
version = "2.0.117" version = "2.0.118"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" checksum = "1b9ae57f904213ebb649ce6895b8a66c66f0203b9319718f69a5612a065b1422"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
...@@ -729,7 +664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -729,7 +664,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd"
dependencies = [ dependencies = [
"fastrand", "fastrand",
"getrandom 0.4.2", "getrandom 0.4.3",
"once_cell", "once_cell",
"rustix", "rustix",
"windows-sys 0.61.2", "windows-sys 0.61.2",
...@@ -785,6 +720,7 @@ dependencies = [ ...@@ -785,6 +720,7 @@ dependencies = [
"libc", "libc",
"mio", "mio",
"pin-project-lite", "pin-project-lite",
"signal-hook-registry",
"socket2", "socket2",
"tokio-macros", "tokio-macros",
"windows-sys 0.61.2", "windows-sys 0.61.2",
...@@ -842,12 +778,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index" ...@@ -842,12 +778,6 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
[[package]] [[package]]
name = "unicode-xid"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853"
[[package]]
name = "version_check" name = "version_check"
version = "0.9.5" version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -871,54 +801,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" ...@@ -871,54 +801,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b"
[[package]] [[package]]
name = "wasip2" name = "wasip2"
version = "1.0.3+wasi-0.2.9" version = "1.0.4+wasi-0.2.12"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487"
dependencies = [ dependencies = [
"wit-bindgen 0.57.1", "wit-bindgen",
]
[[package]]
name = "wasip3"
version = "0.4.0+wasi-0.3.0-rc-2026-01-06"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5"
dependencies = [
"wit-bindgen 0.51.0",
]
[[package]]
name = "wasm-encoder"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319"
dependencies = [
"leb128fmt",
"wasmparser",
]
[[package]]
name = "wasm-metadata"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909"
dependencies = [
"anyhow",
"indexmap",
"wasm-encoder",
"wasmparser",
]
[[package]]
name = "wasmparser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe"
dependencies = [
"bitflags 2.11.1",
"hashbrown 0.15.5",
"indexmap",
"semver",
] ]
[[package]] [[package]]
...@@ -1052,99 +939,11 @@ dependencies = [ ...@@ -1052,99 +939,11 @@ dependencies = [
[[package]] [[package]]
name = "wit-bindgen" name = "wit-bindgen"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5"
dependencies = [
"wit-bindgen-rust-macro",
]
[[package]]
name = "wit-bindgen"
version = "0.57.1" version = "0.57.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e"
[[package]] [[package]]
name = "wit-bindgen-core"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc"
dependencies = [
"anyhow",
"heck",
"wit-parser",
]
[[package]]
name = "wit-bindgen-rust"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21"
dependencies = [
"anyhow",
"heck",
"indexmap",
"prettyplease",
"syn",
"wasm-metadata",
"wit-bindgen-core",
"wit-component",
]
[[package]]
name = "wit-bindgen-rust-macro"
version = "0.51.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a"
dependencies = [
"anyhow",
"prettyplease",
"proc-macro2",
"quote",
"syn",
"wit-bindgen-core",
"wit-bindgen-rust",
]
[[package]]
name = "wit-component"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2"
dependencies = [
"anyhow",
"bitflags 2.11.1",
"indexmap",
"log",
"serde",
"serde_derive",
"serde_json",
"wasm-encoder",
"wasm-metadata",
"wasmparser",
"wit-parser",
]
[[package]]
name = "wit-parser"
version = "0.244.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736"
dependencies = [
"anyhow",
"id-arena",
"indexmap",
"log",
"semver",
"serde",
"serde_derive",
"serde_json",
"unicode-xid",
"wasmparser",
]
[[package]]
name = "xxhash-rust" name = "xxhash-rust"
version = "0.8.15" version = "0.8.15"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
...@@ -1152,18 +951,18 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" ...@@ -1152,18 +951,18 @@ checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3"
[[package]] [[package]]
name = "zerocopy" name = "zerocopy"
version = "0.8.50" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" checksum = "ce1022995ff5ff5d841ad7d994facc23098cd40152f2c1d11cd607c6f530653f"
dependencies = [ dependencies = [
"zerocopy-derive", "zerocopy-derive",
] ]
[[package]] [[package]]
name = "zerocopy-derive" name = "zerocopy-derive"
version = "0.8.50" version = "0.8.52"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" checksum = "1ae7f38b72ec2a254e2b87ef277cf2cd4fb97cbebf944faa6f33354da0867930"
dependencies = [ dependencies = [
"proc-macro2", "proc-macro2",
"quote", "quote",
......
...@@ -5,10 +5,10 @@ edition = "2024" ...@@ -5,10 +5,10 @@ edition = "2024"
rust-version = "1.95" rust-version = "1.95"
[dependencies] [dependencies]
tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util"] } tokio = { version = "1.52.3", features = ["macros", "rt-multi-thread", "net", "time", "sync", "fs", "io-util", "process"] }
tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] } tokio-tungstenite = { version = "0.29.0", default-features = false, features = ["handshake"] }
portable-pty = "0.9.0" portable-pty = "0.9.0"
serde = { version = "1.0", features = ["derive"] } serde = { version = "1.0.228", features = ["derive"] }
serde_json = "1.0.150" serde_json = "1.0.150"
base64 = "0.22.1" base64 = "0.22.1"
futures-util = "0.3.32" futures-util = "0.3.32"
......
...@@ -2,11 +2,17 @@ use std::sync::Arc; ...@@ -2,11 +2,17 @@ use std::sync::Arc;
use tokio::net::TcpStream; use tokio::net::TcpStream;
use crate::fs::handle_fs_session; use crate::fs::{FsPermission, handle_fs_session};
const MAX_WS_MESSAGE_SIZE: usize = 16 * 1024 * 1024; const MAX_WS_MESSAGE_SIZE: usize = 16 * 1024 * 1024;
const MAX_WS_FRAME_SIZE: usize = 4 * 1024 * 1024; const MAX_WS_FRAME_SIZE: usize = 4 * 1024 * 1024;
use crate::terminal::handle_terminal_session; use crate::terminal::handle_terminal_session;
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum Channel {
Fs,
Terminal,
}
fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig { fn ws_config() -> tokio_tungstenite::tungstenite::protocol::WebSocketConfig {
tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default() tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
.max_message_size(Some(MAX_WS_MESSAGE_SIZE)) .max_message_size(Some(MAX_WS_MESSAGE_SIZE))
...@@ -40,23 +46,26 @@ fn build_unauthorized_response( ...@@ -40,23 +46,26 @@ fn build_unauthorized_response(
resp resp
} }
fn parse_channel(path: &str) -> Option<Channel> {
match path {
"/fs" => Some(Channel::Fs),
"/terminal" => Some(Channel::Terminal),
_ => None,
}
}
#[allow(clippy::result_large_err)] #[allow(clippy::result_large_err)]
pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String>) { pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String>) {
let mut path = String::new(); let mut channel = None;
let mut fs_permission = "write".to_string(); let mut fs_permission = FsPermission::Read;
let ws_stream = match tokio_tungstenite::accept_hdr_async_with_config( let ws_stream = match tokio_tungstenite::accept_hdr_async_with_config(
stream, stream,
|req: &tokio_tungstenite::tungstenite::handshake::server::Request, |req: &tokio_tungstenite::tungstenite::handshake::server::Request,
response: tokio_tungstenite::tungstenite::handshake::server::Response| { response: tokio_tungstenite::tungstenite::handshake::server::Response| {
path = req.uri().path().to_string(); let parsed_channel = parse_channel(req.uri().path())
let expected_channel = if path.ends_with("/terminal") { .ok_or_else(|| build_unauthorized_response("Unknown websocket path"))?;
"terminal" channel = Some(parsed_channel);
} else if path.ends_with("/fs") {
"fs"
} else {
return Err(build_unauthorized_response("Unknown websocket path"));
};
let token_opt = extract_token(req); let token_opt = extract_token(req);
...@@ -66,13 +75,13 @@ pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String> ...@@ -66,13 +75,13 @@ pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String>
)); ));
} }
fs_permission = fs_permission = match extract_query_value(req, "permission") {
extract_query_value(req, "permission").unwrap_or_else(|| "read".to_string()); Some(value) => FsPermission::parse(&value)
if fs_permission != "read" && fs_permission != "write" { .ok_or_else(|| build_unauthorized_response("Invalid fs permission"))?,
return Err(build_unauthorized_response("Invalid fs permission")); None => FsPermission::Read,
} };
if expected_channel == "terminal" && fs_permission != "write" { if parsed_channel == Channel::Terminal && fs_permission != FsPermission::Write {
return Err(build_unauthorized_response( return Err(build_unauthorized_response(
"Terminal connection requires write permission", "Terminal connection requires write permission",
)); ));
...@@ -91,11 +100,9 @@ pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String> ...@@ -91,11 +100,9 @@ pub async fn handle_connection(stream: TcpStream, expected_password: Arc<String>
} }
}; };
if path.ends_with("/terminal") { match channel {
handle_terminal_session(ws_stream).await; Some(Channel::Terminal) => handle_terminal_session(ws_stream).await,
} else if path.ends_with("/fs") { Some(Channel::Fs) => handle_fs_session(ws_stream, fs_permission).await,
handle_fs_session(ws_stream, fs_permission).await; None => eprintln!("Unknown request path after websocket handshake"),
} else {
eprintln!("Unknown request path for websocket: {}", path);
} }
} }
use std::collections::{BTreeSet, HashSet}; use std::collections::{BTreeSet, HashSet};
use std::path::Path; use std::path::Path;
use std::process::Stdio;
use std::sync::{Arc, OnceLock}; use std::sync::{Arc, OnceLock};
use std::time::{Duration, SystemTime}; use std::time::{Duration, SystemTime};
...@@ -10,8 +11,11 @@ use notify::{Config, Event, EventKind, EventKindMask, RecommendedWatcher, Recurs ...@@ -10,8 +11,11 @@ use notify::{Config, Event, EventKind, EventKindMask, RecommendedWatcher, Recurs
use notify_debouncer_full::{ use notify_debouncer_full::{
DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt, DebounceEventResult, DebouncedEvent, RecommendedCache, new_debouncer_opt,
}; };
use serde::de::DeserializeOwned;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::json; use serde_json::json;
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command;
use tokio::sync::{broadcast, mpsc}; use tokio::sync::{broadcast, mpsc};
use tokio_tungstenite::tungstenite::Message; use tokio_tungstenite::tungstenite::Message;
use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame; use tokio_tungstenite::tungstenite::protocol::frame::CloseFrame;
...@@ -27,6 +31,99 @@ const FS_CHANGE_DEBOUNCE_MS: u64 = 500; ...@@ -27,6 +31,99 @@ const FS_CHANGE_DEBOUNCE_MS: u64 = 500;
const FS_CHANGE_MAX_PATHS: usize = 200; const FS_CHANGE_MAX_PATHS: usize = 200;
const FS_WATCH_BROADCAST_CAPACITY: usize = 128; const FS_WATCH_BROADCAST_CAPACITY: usize = 128;
const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024; const DEFAULT_MAX_FILE_BYTES: u64 = 10 * 1024 * 1024;
const EXEC_TIMEOUT_MS: u64 = 30_000;
const EXEC_MAX_OUTPUT_BYTES: usize = 1024 * 1024;
const DEFAULT_WORKSPACE_PATH: &str = ".";
const DEFAULT_MAX_DEPTH: u64 = 20;
// JSON-RPC 的 params 入口统一走强类型反序列化,避免各 handler 分散手写 Value 字段读取。
fn parse_params<T>(params: Option<serde_json::Value>) -> Result<T, String>
where
T: DeserializeOwned,
{
let params = params.ok_or_else(|| "Params required".to_string())?;
serde_json::from_value(params).map_err(|err| err.to_string())
}
fn mtime_secs(metadata: &std::fs::Metadata) -> u64 {
metadata
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
#[derive(Debug, Deserialize)]
struct PathParams {
path: String,
}
#[derive(Debug, Deserialize)]
struct WriteFileParams {
path: String,
content: String,
#[serde(default, rename = "old_mtime")]
old_mtime: Option<u64>,
}
#[derive(Debug, Deserialize)]
#[serde(default, rename_all = "camelCase")]
struct ReadDirRecursiveParams {
path: String,
max_depth: u64,
exclude_names: Option<Vec<String>>,
}
impl Default for ReadDirRecursiveParams {
fn default() -> Self {
Self {
path: DEFAULT_WORKSPACE_PATH.to_string(),
max_depth: DEFAULT_MAX_DEPTH,
exclude_names: None,
}
}
}
#[derive(Debug, Deserialize)]
struct MoveParams {
from: String,
to: String,
}
#[derive(Debug, Deserialize)]
#[serde(rename_all = "camelCase")]
struct ExecParams {
command: String,
#[serde(default)]
timeout_ms: Option<u64>,
}
impl ExecParams {
fn timeout_ms(&self) -> u64 {
self.timeout_ms.unwrap_or(EXEC_TIMEOUT_MS)
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
pub enum FsPermission {
Read,
Write,
}
impl FsPermission {
pub fn parse(value: &str) -> Option<Self> {
match value {
"read" => Some(Self::Read),
"write" => Some(Self::Write),
_ => None,
}
}
fn can_write(self) -> bool {
matches!(self, Self::Write)
}
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
struct FsChangeBatch { struct FsChangeBatch {
...@@ -42,14 +139,18 @@ struct FsWatchHub { ...@@ -42,14 +139,18 @@ struct FsWatchHub {
static FS_WATCH_HUB: OnceLock<FsWatchHub> = OnceLock::new(); static FS_WATCH_HUB: OnceLock<FsWatchHub> = OnceLock::new();
fn max_file_bytes() -> u64 { fn parse_max_file_bytes(value: Option<&str>) -> u64 {
std::env::var("FASTGPT_IDE_MAX_FILE_BYTES") value
.ok()
.and_then(|value| value.parse::<u64>().ok()) .and_then(|value| value.parse::<u64>().ok())
.filter(|value| *value > 0) .filter(|value| *value > 0)
.unwrap_or(DEFAULT_MAX_FILE_BYTES) .unwrap_or(DEFAULT_MAX_FILE_BYTES)
} }
fn max_file_bytes() -> u64 {
let value = std::env::var("FASTGPT_IDE_MAX_FILE_BYTES").ok();
parse_max_file_bytes(value.as_deref())
}
fn default_exclude_names() -> HashSet<String> { fn default_exclude_names() -> HashSet<String> {
DEFAULT_EXCLUDED_NAMES DEFAULT_EXCLUDED_NAMES
.iter() .iter()
...@@ -271,12 +372,8 @@ async fn forward_fs_change_batches( ...@@ -271,12 +372,8 @@ async fn forward_fs_change_batches(
} }
async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: PathParams = parse_params(params)?;
let path_str = params let clean_path = sanitize_path(&params.path).await?;
.get("path")
.and_then(|v| v.as_str())
.ok_or("path param required")?;
let clean_path = sanitize_path(path_str).await?;
let mut entries = Vec::new(); let mut entries = Vec::new();
let mut dir = tokio::fs::read_dir(clean_path) let mut dir = tokio::fs::read_dir(clean_path)
...@@ -288,12 +385,7 @@ async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json ...@@ -288,12 +385,7 @@ async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json
let name = entry.file_name().to_string_lossy().into_owned(); let name = entry.file_name().to_string_lossy().into_owned();
let is_dir = metadata.is_dir(); let is_dir = metadata.is_dir();
let size = metadata.len(); let size = metadata.len();
let mtime = metadata let mtime = mtime_secs(&metadata);
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
entries.push(json!({ entries.push(json!({
"name": name, "name": name,
...@@ -307,12 +399,8 @@ async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json ...@@ -307,12 +399,8 @@ async fn handle_read_dir(params: Option<serde_json::Value>) -> Result<serde_json
} }
async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: PathParams = parse_params(params)?;
let path_str = params let clean_path = sanitize_path(&params.path).await?;
.get("path")
.and_then(|v| v.as_str())
.ok_or("path param required")?;
let clean_path = sanitize_path(path_str).await?;
let mut file = tokio::fs::File::open(&clean_path) let mut file = tokio::fs::File::open(&clean_path)
.await .await
...@@ -332,19 +420,13 @@ async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_jso ...@@ -332,19 +420,13 @@ async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_jso
} }
let mut content_bytes = Vec::with_capacity(file_size as usize); let mut content_bytes = Vec::with_capacity(file_size as usize);
use tokio::io::AsyncReadExt;
file.read_to_end(&mut content_bytes) file.read_to_end(&mut content_bytes)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let mut content_b64 = String::with_capacity(content_bytes.len().div_ceil(3) * 4); let mut content_b64 = String::with_capacity(content_bytes.len().div_ceil(3) * 4);
base64::engine::general_purpose::STANDARD.encode_string(&content_bytes, &mut content_b64); base64::engine::general_purpose::STANDARD.encode_string(&content_bytes, &mut content_b64);
let mtime = metadata let mtime = mtime_secs(&metadata);
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(json!({ Ok(json!({
"content": content_b64, "content": content_b64,
...@@ -353,33 +435,19 @@ async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_jso ...@@ -353,33 +435,19 @@ async fn handle_read_file(params: Option<serde_json::Value>) -> Result<serde_jso
} }
async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: WriteFileParams = parse_params(params)?;
let path_str = params
.get("path") let clean_path = sanitize_create_path(&params.path).await?;
.and_then(|v| v.as_str())
.ok_or("path param required")?;
let content_b64 = params
.get("content")
.and_then(|v| v.as_str())
.ok_or("content param required")?;
let clean_path = sanitize_create_path(path_str).await?;
if clean_path.exists() { if clean_path.exists() {
let metadata = tokio::fs::metadata(&clean_path) let metadata = tokio::fs::metadata(&clean_path)
.await .await
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let actual_mtime = metadata let actual_mtime = mtime_secs(&metadata);
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// 校验修改时间防冲突 // 校验修改时间防冲突
if params if params
.get("old_mtime") .old_mtime
.and_then(|v| v.as_u64())
.is_some_and(|old_val| old_val != actual_mtime) .is_some_and(|old_val| old_val != actual_mtime)
{ {
return Err("conflict".to_string()); return Err("conflict".to_string());
...@@ -387,7 +455,7 @@ async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_js ...@@ -387,7 +455,7 @@ async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_js
} }
let raw_bytes = base64::engine::general_purpose::STANDARD let raw_bytes = base64::engine::general_purpose::STANDARD
.decode(content_b64) .decode(&params.content)
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let max_file_bytes = max_file_bytes(); let max_file_bytes = max_file_bytes();
if raw_bytes.len() as u64 > max_file_bytes { if raw_bytes.len() as u64 > max_file_bytes {
...@@ -418,12 +486,7 @@ async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_js ...@@ -418,12 +486,7 @@ async fn handle_write_file(params: Option<serde_json::Value>) -> Result<serde_js
.map_err(|e| e.to_string())?; .map_err(|e| e.to_string())?;
let metadata = file.metadata().await.map_err(|e| e.to_string())?; let metadata = file.metadata().await.map_err(|e| e.to_string())?;
let new_mtime = metadata let new_mtime = mtime_secs(&metadata);
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
Ok(json!({ "mtime": new_mtime })) Ok(json!({ "mtime": new_mtime }))
} }
...@@ -482,12 +545,7 @@ async fn scan_dir_recursive( ...@@ -482,12 +545,7 @@ async fn scan_dir_recursive(
tasks.push(async move { tasks.push(async move {
let metadata = entry.metadata().await.map_err(|e| e.to_string())?; let metadata = entry.metadata().await.map_err(|e| e.to_string())?;
let size = metadata.len(); let size = metadata.len();
let mtime = metadata let mtime = mtime_secs(&metadata);
.modified()
.unwrap_or(SystemTime::UNIX_EPOCH)
.duration_since(SystemTime::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
let mut children = None; let mut children = None;
if is_dir && level < max_depth { if is_dir && level < max_depth {
...@@ -540,25 +598,12 @@ async fn scan_dir_recursive( ...@@ -540,25 +598,12 @@ async fn scan_dir_recursive(
async fn handle_read_dir_recursive( async fn handle_read_dir_recursive(
params: Option<serde_json::Value>, params: Option<serde_json::Value>,
) -> Result<serde_json::Value, String> { ) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: ReadDirRecursiveParams = parse_params(params)?;
let path_str = params.get("path").and_then(|v| v.as_str()).unwrap_or("."); let clean_path = sanitize_path(&params.path).await?;
let clean_path = sanitize_path(path_str).await?; let max_depth = params.max_depth.min(50) as usize;
let max_depth = params
.get("maxDepth")
.and_then(|v| v.as_u64())
.map(|depth| depth.min(50) as usize)
.unwrap_or(20);
let exclude_set: HashSet<String> = params let exclude_set: HashSet<String> = params
.get("excludeNames") .exclude_names
.and_then(|v| v.as_array()) .map(|items| items.into_iter().collect())
.map(|items| {
items
.iter()
.filter_map(|item| item.as_str())
.map(str::to_string)
.collect()
})
.unwrap_or_else(default_exclude_names); .unwrap_or_else(default_exclude_names);
let exclude_names = Arc::new(exclude_set); let exclude_names = Arc::new(exclude_set);
...@@ -581,12 +626,8 @@ async fn handle_read_dir_recursive( ...@@ -581,12 +626,8 @@ async fn handle_read_dir_recursive(
} }
async fn handle_mkdir(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_mkdir(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: PathParams = parse_params(params)?;
let path_str = params let clean_path = sanitize_create_path(&params.path).await?;
.get("path")
.and_then(|v| v.as_str())
.ok_or("path param required")?;
let clean_path = sanitize_create_path(path_str).await?;
tokio::fs::create_dir_all(&clean_path) tokio::fs::create_dir_all(&clean_path)
.await .await
...@@ -595,12 +636,8 @@ async fn handle_mkdir(params: Option<serde_json::Value>) -> Result<serde_json::V ...@@ -595,12 +636,8 @@ async fn handle_mkdir(params: Option<serde_json::Value>) -> Result<serde_json::V
} }
async fn handle_delete(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_delete(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: PathParams = parse_params(params)?;
let path_str = params let clean_path = sanitize_path(&params.path).await?;
.get("path")
.and_then(|v| v.as_str())
.ok_or("path param required")?;
let clean_path = sanitize_path(path_str).await?;
if is_workspace_root_path(&clean_path) { if is_workspace_root_path(&clean_path) {
return Err("Refusing to delete workspace root".to_string()); return Err("Refusing to delete workspace root".to_string());
} }
...@@ -618,18 +655,10 @@ async fn handle_delete(params: Option<serde_json::Value>) -> Result<serde_json:: ...@@ -618,18 +655,10 @@ async fn handle_delete(params: Option<serde_json::Value>) -> Result<serde_json::
} }
async fn handle_move(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> { async fn handle_move(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params = params.ok_or("Params required")?; let params: MoveParams = parse_params(params)?;
let from_str = params
.get("from") let clean_from = sanitize_existing_workspace_entry_path(&params.from).await?;
.and_then(|v| v.as_str()) let clean_to = sanitize_create_path(&params.to).await?;
.ok_or("from param required")?;
let to_str = params
.get("to")
.and_then(|v| v.as_str())
.ok_or("to param required")?;
let clean_from = sanitize_existing_workspace_entry_path(from_str).await?;
let clean_to = sanitize_create_path(to_str).await?;
if is_workspace_root_path(&clean_from) { if is_workspace_root_path(&clean_from) {
return Err("Refusing to move workspace root".to_string()); return Err("Refusing to move workspace root".to_string());
} }
...@@ -651,15 +680,93 @@ async fn handle_move(params: Option<serde_json::Value>) -> Result<serde_json::Va ...@@ -651,15 +680,93 @@ async fn handle_move(params: Option<serde_json::Value>) -> Result<serde_json::Va
Ok(json!({ "success": true })) Ok(json!({ "success": true }))
} }
async fn read_limited_output<R>(mut reader: R) -> Result<Vec<u8>, String>
where
R: AsyncRead + Unpin,
{
let mut output = Vec::new();
let mut buffer = [0u8; 8192];
loop {
let read_len = reader.read(&mut buffer).await.map_err(|e| e.to_string())?;
if read_len == 0 {
break;
}
// 达到返回上限后继续 drain pipe,避免子进程因 stdout/stderr 写满而卡住。
let remaining = EXEC_MAX_OUTPUT_BYTES.saturating_sub(output.len());
if remaining > 0 {
output.extend_from_slice(&buffer[..read_len.min(remaining)]);
}
}
Ok(output)
}
async fn handle_exec(params: Option<serde_json::Value>) -> Result<serde_json::Value, String> {
let params: ExecParams = parse_params(params)?;
if params.command.trim().is_empty() {
return Err("command param required".to_string());
}
let timeout_ms = params.timeout_ms();
let mut child = Command::new("sh")
.arg("-lc")
.arg(params.command)
.current_dir(get_workspace_root())
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.spawn()
.map_err(|e| e.to_string())?;
let stdout = child
.stdout
.take()
.ok_or_else(|| "Failed to capture command stdout".to_string())?;
let stderr = child
.stderr
.take()
.ok_or_else(|| "Failed to capture command stderr".to_string())?;
let output = match tokio::time::timeout(Duration::from_millis(timeout_ms), async {
let (status, stdout, stderr) = tokio::try_join!(
async { child.wait().await.map_err(|e| e.to_string()) },
read_limited_output(stdout),
read_limited_output(stderr)
)?;
Ok::<_, String>((status, stdout, stderr))
})
.await
{
Ok(output) => output?,
Err(_) => {
return Ok(json!({
"exitCode": -1,
"stdout": "",
"stderr": format!("Command timed out after {}ms", timeout_ms),
}));
}
};
let (status, stdout, stderr) = output;
Ok(json!({
"exitCode": status.code().unwrap_or(-1),
"stdout": String::from_utf8_lossy(&stdout).to_string(),
"stderr": String::from_utf8_lossy(&stderr).to_string(),
}))
}
fn is_write_fs_method(method: &str) -> bool { fn is_write_fs_method(method: &str) -> bool {
matches!( matches!(
method, method,
"fs/write_file" | "fs/mkdir" | "fs/delete" | "fs/move" "fs/write_file" | "fs/mkdir" | "fs/delete" | "fs/move" | "fs/exec"
) )
} }
async fn handle_fs_request(req: JsonRpcRequest, permission: &str) -> JsonRpcResponse { async fn handle_fs_request(req: JsonRpcRequest, permission: FsPermission) -> JsonRpcResponse {
if permission != "write" && is_write_fs_method(req.method.as_str()) { if !permission.can_write() && is_write_fs_method(req.method.as_str()) {
return JsonRpcResponse { return JsonRpcResponse {
jsonrpc: "2.0".to_string(), jsonrpc: "2.0".to_string(),
id: req.id, id: req.id,
...@@ -690,6 +797,7 @@ async fn handle_fs_request(req: JsonRpcRequest, permission: &str) -> JsonRpcResp ...@@ -690,6 +797,7 @@ async fn handle_fs_request(req: JsonRpcRequest, permission: &str) -> JsonRpcResp
"fs/mkdir" => handle_mkdir(req.params).await.map_err(|e| (-32603, e)), "fs/mkdir" => handle_mkdir(req.params).await.map_err(|e| (-32603, e)),
"fs/delete" => handle_delete(req.params).await.map_err(|e| (-32603, e)), "fs/delete" => handle_delete(req.params).await.map_err(|e| (-32603, e)),
"fs/move" => handle_move(req.params).await.map_err(|e| (-32603, e)), "fs/move" => handle_move(req.params).await.map_err(|e| (-32603, e)),
"fs/exec" => handle_exec(req.params).await.map_err(|e| (-32603, e)),
_ => Err((-32601, "Method not found".to_string())), _ => Err((-32601, "Method not found".to_string())),
}; };
...@@ -711,7 +819,7 @@ async fn handle_fs_request(req: JsonRpcRequest, permission: &str) -> JsonRpcResp ...@@ -711,7 +819,7 @@ async fn handle_fs_request(req: JsonRpcRequest, permission: &str) -> JsonRpcResp
pub async fn handle_fs_session<S>( pub async fn handle_fs_session<S>(
ws_stream: tokio_tungstenite::WebSocketStream<S>, ws_stream: tokio_tungstenite::WebSocketStream<S>,
permission: String, permission: FsPermission,
) where ) where
S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static, S: tokio::io::AsyncRead + tokio::io::AsyncWrite + Unpin + Send + 'static,
{ {
...@@ -767,7 +875,7 @@ pub async fn handle_fs_session<S>( ...@@ -767,7 +875,7 @@ pub async fn handle_fs_session<S>(
continue; continue;
}; };
let response = handle_fs_request(req, &permission).await; let response = handle_fs_request(req, permission).await;
if let Ok(response_text) = serde_json::to_string(&response) if let Ok(response_text) = serde_json::to_string(&response)
&& outbound_tx && outbound_tx
.send(Message::Text(response_text.into())) .send(Message::Text(response_text.into()))
...@@ -811,101 +919,85 @@ mod tests { ...@@ -811,101 +919,85 @@ mod tests {
use super::*; use super::*;
use crate::workspace::init_test_workspace; use crate::workspace::init_test_workspace;
use notify::event::{CreateKind, EventAttributes, EventKind}; use notify::event::{CreateKind, EventAttributes, EventKind};
use serde_json::json; use serde_json::{Value, json};
use std::fs; use std::fs;
use std::path::PathBuf;
use std::time::Instant; use std::time::Instant;
fn fs_request(method: &str, params: Option<Value>) -> JsonRpcRequest {
JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: json!(1),
method: method.to_string(),
params,
}
}
async fn fs_ok(method: &str, params: Value) -> Value {
let resp = handle_fs_request(fs_request(method, Some(params)), FsPermission::Write).await;
match (resp.result, resp.error) {
(Some(result), None) => result,
(_, Some(error)) => panic!("unexpected fs error for {method}: {error:?}"),
(None, None) => panic!("missing fs result for {method}"),
}
}
async fn fs_err(method: &str, params: Option<Value>, permission: FsPermission) -> JsonRpcError {
let resp = handle_fs_request(fs_request(method, params), permission).await;
match (resp.result, resp.error) {
(None, Some(error)) => error,
(Some(result), _) => panic!("unexpected fs result for {method}: {result:?}"),
(None, None) => panic!("missing fs error for {method}"),
}
}
fn notify_event(kind: EventKind, paths: Vec<PathBuf>) -> Event {
Event {
kind,
paths,
attrs: EventAttributes::new(),
}
}
#[tokio::test] #[tokio::test]
async fn test_handle_fs_request_jsonrpc_workflow() { async fn test_fs_crud_and_tree_workflow() {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
let _ = fs::remove_dir_all(temp_workspace.join("src_test")); let _ = fs::remove_dir_all(temp_workspace.join("src_test"));
// 1. 测试创建目录 (fs/mkdir) let mkdir_result = fs_ok("fs/mkdir", json!({ "path": "src_test" })).await;
let mkdir_req = JsonRpcRequest { assert_eq!(mkdir_result["success"], json!(true));
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(1.into()),
method: "fs/mkdir".to_string(),
params: Some(json!({ "path": "src_test" })),
};
let resp = handle_fs_request(mkdir_req, "write").await;
assert!(resp.error.is_none());
assert_eq!(
resp.result.unwrap().get("success").unwrap().as_bool(),
Some(true)
);
// 2. 测试写入文件 (fs/write_file) - Base64 编码的 "Hello Rust!" 是 "SGVsbG8gUnVzdCE=" let write_result = fs_ok(
let write_req = JsonRpcRequest { "fs/write_file",
jsonrpc: "2.0".to_string(), json!({
id: serde_json::Value::Number(2.into()),
method: "fs/write_file".to_string(),
params: Some(json!({
"path": "src_test/hello.txt", "path": "src_test/hello.txt",
"content": "SGVsbG8gUnVzdCE=" "content": "SGVsbG8gUnVzdCE="
})), }),
}; )
let resp = handle_fs_request(write_req, "write").await; .await;
assert!(resp.error.is_none()); assert!(write_result["mtime"].as_u64().is_some());
let mtime = resp.result.unwrap().get("mtime").unwrap().as_u64();
assert!(mtime.is_some());
// 2.1 测试写入嵌套缺失父目录时会自动创建父目录 fs_ok(
let nested_write_req = JsonRpcRequest { "fs/write_file",
jsonrpc: "2.0".to_string(), json!({
id: serde_json::Value::Number(21.into()),
method: "fs/write_file".to_string(),
params: Some(json!({
"path": "missing_parent/a/hello.txt", "path": "missing_parent/a/hello.txt",
"content": "SGVsbG8gUnVzdCE=" "content": "SGVsbG8gUnVzdCE="
})), }),
}; )
let resp = handle_fs_request(nested_write_req, "write").await; .await;
assert!(resp.error.is_none());
assert!(temp_workspace.join("missing_parent/a/hello.txt").exists()); assert!(temp_workspace.join("missing_parent/a/hello.txt").exists());
// 3. 测试读取文件 (fs/read_file) let read_result = fs_ok("fs/read_file", json!({ "path": "src_test/hello.txt" })).await;
let read_req = JsonRpcRequest { assert_eq!(read_result["content"], json!("SGVsbG8gUnVzdCE="));
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(3.into()), let tree_result = fs_ok("fs/read_dir_recursive", json!({ "path": "." })).await;
method: "fs/read_file".to_string(), let files = tree_result["files"].as_array().unwrap();
params: Some(json!({ "path": "src_test/hello.txt" })),
};
let resp = handle_fs_request(read_req, "write").await;
assert!(resp.error.is_none());
let result_obj = resp.result.unwrap();
let content_b64 = result_obj.get("content").unwrap().as_str().unwrap();
assert_eq!(content_b64, "SGVsbG8gUnVzdCE=");
// 4. 测试递归读取文件树 (fs/read_dir_recursive)
let read_tree_req = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(4.into()),
method: "fs/read_dir_recursive".to_string(),
params: Some(json!({ "path": "." })),
};
let resp = handle_fs_request(read_tree_req, "write").await;
assert!(resp.error.is_none());
let tree_res = resp.result.unwrap();
let files = tree_res.get("files").unwrap().as_array().unwrap();
assert!(!files.is_empty()); assert!(!files.is_empty());
let src_node = files let src_node = files
.iter() .iter()
.find(|node| node.get("name").and_then(|value| value.as_str()) == Some("src_test")) .find(|node| node["name"] == json!("src_test"))
.expect("src_test node should be present"); .expect("src_test node should be present");
assert_eq!(src_node.get("type").unwrap().as_str(), Some("directory")); assert_eq!(src_node["type"], json!("directory"));
// 5. 测试非法方法名 (fs/invalid_method) - 应返回标准错误
let invalid_req = JsonRpcRequest {
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(5.into()),
method: "fs/invalid_method".to_string(),
params: None,
};
let resp = handle_fs_request(invalid_req, "write").await;
assert!(resp.result.is_none());
let err = resp.error.unwrap();
assert_eq!(err.code, -32601);
assert!(err.message.contains("Method not found"));
} }
#[tokio::test] #[tokio::test]
...@@ -913,28 +1005,18 @@ mod tests { ...@@ -913,28 +1005,18 @@ mod tests {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
let _ = fs::remove_dir_all(temp_workspace.join("nested_create_test")); let _ = fs::remove_dir_all(temp_workspace.join("nested_create_test"));
let mkdir_req = JsonRpcRequest { fs_ok("fs/mkdir", json!({ "path": "nested_create_test/a/b" })).await;
jsonrpc: "2.0".to_string(),
id: serde_json::Value::Number(1.into()),
method: "fs/mkdir".to_string(),
params: Some(json!({ "path": "nested_create_test/a/b" })),
};
let resp = handle_fs_request(mkdir_req, "write").await;
assert!(resp.error.is_none());
assert!(temp_workspace.join("nested_create_test/a/b").is_dir()); assert!(temp_workspace.join("nested_create_test/a/b").is_dir());
fs::write(temp_workspace.join("nested_create_test/source.txt"), "move").unwrap(); fs::write(temp_workspace.join("nested_create_test/source.txt"), "move").unwrap();
let move_req = JsonRpcRequest { fs_ok(
jsonrpc: "2.0".to_string(), "fs/move",
id: serde_json::Value::Number(2.into()), json!({
method: "fs/move".to_string(),
params: Some(json!({
"from": "nested_create_test/source.txt", "from": "nested_create_test/source.txt",
"to": "nested_create_test/c/d/target.txt" "to": "nested_create_test/c/d/target.txt"
})), }),
}; )
let resp = handle_fs_request(move_req, "write").await; .await;
assert!(resp.error.is_none());
assert!( assert!(
temp_workspace temp_workspace
.join("nested_create_test/c/d/target.txt") .join("nested_create_test/c/d/target.txt")
...@@ -954,18 +1036,15 @@ mod tests { ...@@ -954,18 +1036,15 @@ mod tests {
let _ = fs::remove_dir_all(&moved_link_path); let _ = fs::remove_dir_all(&moved_link_path);
std::os::unix::fs::symlink(outside.path(), &link_path).unwrap(); std::os::unix::fs::symlink(outside.path(), &link_path).unwrap();
let move_req = JsonRpcRequest { fs_ok(
jsonrpc: "2.0".to_string(), "fs/move",
id: serde_json::Value::Number(1.into()), json!({
method: "fs/move".to_string(),
params: Some(json!({
"from": "move_symlink_source", "from": "move_symlink_source",
"to": "move_symlink_target" "to": "move_symlink_target"
})), }),
}; )
let resp = handle_fs_request(move_req, "write").await; .await;
assert!(resp.error.is_none());
assert!(!link_path.exists()); assert!(!link_path.exists());
assert!( assert!(
fs::symlink_metadata(&moved_link_path) fs::symlink_metadata(&moved_link_path)
...@@ -980,70 +1059,108 @@ mod tests { ...@@ -980,70 +1059,108 @@ mod tests {
async fn test_read_only_ticket_rejects_write_methods() { async fn test_read_only_ticket_rejects_write_methods() {
let _temp_workspace = init_test_workspace(); let _temp_workspace = init_test_workspace();
let write_req = JsonRpcRequest { let write_err = fs_err(
jsonrpc: "2.0".to_string(), "fs/write_file",
id: serde_json::Value::Number(1.into()), Some(json!({
method: "fs/write_file".to_string(),
params: Some(json!({
"path": "readonly.txt", "path": "readonly.txt",
"content": "SGVsbG8=" "content": "SGVsbG8="
})), })),
}; FsPermission::Read,
)
let resp = handle_fs_request(write_req, "read").await; .await;
assert!(resp.result.is_none()); assert_eq!(write_err.code, -32003);
let err = resp.error.unwrap(); assert!(write_err.message.contains("read-only"));
assert_eq!(err.code, -32003);
assert!(err.message.contains("read-only")); let exec_err = fs_err(
"fs/exec",
Some(json!({ "command": "echo denied" })),
FsPermission::Read,
)
.await;
assert_eq!(exec_err.code, -32003);
assert!(exec_err.message.contains("read-only"));
} }
#[tokio::test] #[tokio::test]
async fn test_delete_and_move_reject_workspace_root() { async fn test_delete_and_move_reject_workspace_root() {
let _temp_workspace = init_test_workspace(); let _temp_workspace = init_test_workspace();
let delete_req = JsonRpcRequest { let delete_err = fs_err(
jsonrpc: "2.0".to_string(), "fs/delete",
id: serde_json::Value::Number(1.into()), Some(json!({ "path": "." })),
method: "fs/delete".to_string(), FsPermission::Write,
params: Some(json!({ "path": "." })), )
}; .await;
let resp = handle_fs_request(delete_req, "write").await; assert!(delete_err.message.contains("workspace root"));
assert!(resp.result.is_none());
assert!(resp.error.unwrap().message.contains("workspace root")); let move_err = fs_err(
"fs/move",
Some(json!({ "from": ".", "to": "moved-root" })),
FsPermission::Write,
)
.await;
assert!(move_err.message.contains("workspace root"));
}
let move_req = JsonRpcRequest { #[tokio::test]
jsonrpc: "2.0".to_string(), async fn test_unknown_fs_method_returns_jsonrpc_error() {
id: serde_json::Value::Number(2.into()), let _temp_workspace = init_test_workspace();
method: "fs/move".to_string(),
params: Some(json!({ "from": ".", "to": "moved-root" })), let err = fs_err("fs/invalid_method", None, FsPermission::Write).await;
}; assert_eq!(err.code, -32601);
let resp = handle_fs_request(move_req, "write").await; assert!(err.message.contains("Method not found"));
assert!(resp.result.is_none());
assert!(resp.error.unwrap().message.contains("workspace root"));
} }
#[test] #[test]
fn test_max_file_bytes_reads_positive_env() { fn test_parse_max_file_bytes() {
unsafe { assert_eq!(parse_max_file_bytes(Some("2048")), 2048);
std::env::set_var("FASTGPT_IDE_MAX_FILE_BYTES", "2048"); assert_eq!(parse_max_file_bytes(Some("0")), DEFAULT_MAX_FILE_BYTES);
} assert_eq!(parse_max_file_bytes(Some("bad")), DEFAULT_MAX_FILE_BYTES);
assert_eq!(max_file_bytes(), 2048); assert_eq!(parse_max_file_bytes(None), DEFAULT_MAX_FILE_BYTES);
unsafe { }
std::env::remove_var("FASTGPT_IDE_MAX_FILE_BYTES");
} #[tokio::test]
async fn test_exec_runs_in_workspace_and_returns_output() {
let temp_workspace = init_test_workspace();
let result = fs_ok("fs/exec", json!({ "command": "pwd && printf done" })).await;
assert_eq!(result["exitCode"], json!(0));
let stdout = result["stdout"].as_str().unwrap();
assert!(stdout.contains(temp_workspace.to_str().unwrap()));
assert!(stdout.contains("done"));
}
#[tokio::test]
async fn test_exec_returns_non_zero_exit_code_and_stderr() {
let _temp_workspace = init_test_workspace();
let result = fs_ok("fs/exec", json!({ "command": "printf err >&2; exit 7" })).await;
assert_eq!(result["exitCode"], json!(7));
assert_eq!(result["stderr"], json!("err"));
}
#[tokio::test]
async fn test_exec_respects_timeout_ms() {
let _temp_workspace = init_test_workspace();
let result = fs_ok("fs/exec", json!({ "command": "sleep 2", "timeoutMs": 1 })).await;
assert_eq!(result["exitCode"], json!(-1));
assert!(result["stderr"].as_str().unwrap().contains("timed out"));
} }
#[test] #[test]
fn test_collect_fs_event_paths_normalizes_paths() { fn test_collect_fs_event_paths_normalizes_paths() {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
let event = Event { let event = notify_event(
kind: EventKind::Create(CreateKind::File), EventKind::Create(CreateKind::File),
paths: vec![ vec![
temp_workspace.join("src").join("index.ts"), temp_workspace.join("src").join("index.ts"),
temp_workspace.join("package.json"), temp_workspace.join("package.json"),
], ],
attrs: EventAttributes::new(), );
};
let paths = collect_fs_event_paths(&event); let paths = collect_fs_event_paths(&event);
assert_eq!(paths, vec!["src/index.ts", "package.json"]); assert_eq!(paths, vec!["src/index.ts", "package.json"]);
...@@ -1052,9 +1169,9 @@ mod tests { ...@@ -1052,9 +1169,9 @@ mod tests {
#[test] #[test]
fn test_collect_fs_event_paths_does_not_hardcode_common_build_dirs() { fn test_collect_fs_event_paths_does_not_hardcode_common_build_dirs() {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
let event = Event { let event = notify_event(
kind: EventKind::Create(CreateKind::File), EventKind::Create(CreateKind::File),
paths: vec![ vec![
temp_workspace temp_workspace
.join("node_modules") .join("node_modules")
.join("pkg") .join("pkg")
...@@ -1063,8 +1180,7 @@ mod tests { ...@@ -1063,8 +1180,7 @@ mod tests {
temp_workspace.join("dist").join("index.js"), temp_workspace.join("dist").join("index.js"),
temp_workspace.join("build").join("index.js"), temp_workspace.join("build").join("index.js"),
], ],
attrs: EventAttributes::new(), );
};
let paths = collect_fs_event_paths(&event); let paths = collect_fs_event_paths(&event);
assert_eq!( assert_eq!(
...@@ -1082,13 +1198,12 @@ mod tests { ...@@ -1082,13 +1198,12 @@ mod tests {
fn test_collect_debounced_event_paths_ignores_access_events() { fn test_collect_debounced_event_paths_ignores_access_events() {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
let events = vec![DebouncedEvent::new( let events = vec![DebouncedEvent::new(
Event { notify_event(
kind: EventKind::Access(notify::event::AccessKind::Open( EventKind::Access(notify::event::AccessKind::Open(
notify::event::AccessMode::Read, notify::event::AccessMode::Read,
)), )),
paths: vec![temp_workspace.join("skills")], vec![temp_workspace.join("skills")],
attrs: EventAttributes::new(), ),
},
Instant::now(), Instant::now(),
)]; )];
......
...@@ -22,7 +22,11 @@ async fn main() { ...@@ -22,7 +22,11 @@ async fn main() {
); );
if !workspace.exists() { if !workspace.exists() {
let _ = tokio::fs::create_dir_all(workspace).await; tokio::fs::create_dir_all(workspace)
.await
.unwrap_or_else(|err| {
panic!("Failed to create workspace root {:?}: {}", workspace, err)
});
} }
let password = Arc::new( let password = Arc::new(
......
...@@ -33,15 +33,21 @@ pub fn load_or_create_ide_agent_password() -> Result<String, String> { ...@@ -33,15 +33,21 @@ pub fn load_or_create_ide_agent_password() -> Result<String, String> {
let password_path = get_password_path(); let password_path = get_password_path();
let password_path = password_path.as_path(); let password_path = password_path.as_path();
match std::fs::read_to_string(password_path) { let read_existing_password = || match std::fs::read_to_string(password_path) {
Ok(content) => { Ok(content) => {
let password = content.trim().to_string(); let password = content.trim().to_string();
if !password.is_empty() { if !password.is_empty() {
return Ok(password); Ok(Some(password))
} else {
Ok(None)
} }
} }
Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} Err(err) if err.kind() == std::io::ErrorKind::NotFound => Ok(None),
Err(err) => return Err(format!("Failed to read IDE Agent password file: {}", err)), Err(err) => Err(format!("Failed to read IDE Agent password file: {}", err)),
};
if let Some(password) = read_existing_password()? {
return Ok(password);
} }
let parent = password_path let parent = password_path
...@@ -52,14 +58,19 @@ pub fn load_or_create_ide_agent_password() -> Result<String, String> { ...@@ -52,14 +58,19 @@ pub fn load_or_create_ide_agent_password() -> Result<String, String> {
let password = generate_ide_agent_password(); let password = generate_ide_agent_password();
let mut options = OpenOptions::new(); let mut options = OpenOptions::new();
options.write(true).create(true).truncate(true); options.write(true).create_new(true);
#[cfg(unix)] #[cfg(unix)]
{ {
options.mode(0o600); options.mode(0o600);
} }
let mut file = options let mut file = match options.open(password_path) {
.open(password_path) Ok(file) => file,
.map_err(|err| format!("Failed to create IDE Agent password file: {}", err))?; Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => {
return read_existing_password()?
.ok_or_else(|| "IDE Agent password file exists but is empty".to_string());
}
Err(err) => return Err(format!("Failed to create IDE Agent password file: {}", err)),
};
file.write_all(format!("{}\n", password).as_bytes()) file.write_all(format!("{}\n", password).as_bytes())
.map_err(|err| format!("Failed to write IDE Agent password file: {}", err))?; .map_err(|err| format!("Failed to write IDE Agent password file: {}", err))?;
......
...@@ -189,40 +189,41 @@ pub fn init_test_workspace() -> &'static Path { ...@@ -189,40 +189,41 @@ pub fn init_test_workspace() -> &'static Path {
mod tests { mod tests {
use super::*; use super::*;
use std::fs; use std::fs;
use std::path::PathBuf;
fn assert_path_ok(result: Result<PathBuf, String>, expected_suffix: &str) -> PathBuf {
let path = result.expect("path should be valid");
assert!(path.ends_with(expected_suffix));
path
}
fn assert_path_err(result: Result<PathBuf, String>) {
assert!(result.is_err());
}
#[tokio::test] #[tokio::test]
async fn test_sanitize_path_success() { async fn test_sanitize_path_success() {
let temp_workspace = init_test_workspace(); let temp_workspace = init_test_workspace();
// 写入一个虚拟测试文件,以确保 validate_path 能正常通过
let test_file = temp_workspace.join("dummy.txt"); let test_file = temp_workspace.join("dummy.txt");
fs::write(&test_file, "dummy").unwrap(); fs::write(&test_file, "dummy").unwrap();
let res = sanitize_path("dummy.txt").await; assert_path_ok(sanitize_path("dummy.txt").await, "dummy.txt");
assert!(res.is_ok()); assert_path_ok(sanitize_path("./dummy.txt").await, "dummy.txt");
let path = res.unwrap();
assert!(path.ends_with("dummy.txt"));
let res = sanitize_path("./dummy.txt").await;
assert!(res.is_ok());
let path = res.unwrap();
assert!(path.ends_with("dummy.txt"));
} }
#[tokio::test] #[tokio::test]
async fn test_sanitize_path_absolute_denied() { async fn test_sanitize_path_absolute_denied() {
let _temp_workspace = init_test_workspace(); let _temp_workspace = init_test_workspace();
let res = sanitize_path("/dummy.txt").await; assert_path_err(sanitize_path("/dummy.txt").await);
assert!(res.is_err());
} }
#[tokio::test] #[tokio::test]
async fn test_sanitize_path_traversal_denied() { async fn test_sanitize_path_traversal_denied() {
let _temp_workspace = init_test_workspace(); let _temp_workspace = init_test_workspace();
let res = sanitize_path("../../../etc/passwd").await; assert_path_err(sanitize_path("../../../etc/passwd").await);
assert!(res.is_err());
} }
#[tokio::test] #[tokio::test]
...@@ -231,17 +232,17 @@ mod tests { ...@@ -231,17 +232,17 @@ mod tests {
let target = temp_workspace.join("nested_missing_parent"); let target = temp_workspace.join("nested_missing_parent");
let _ = fs::remove_dir_all(&target); let _ = fs::remove_dir_all(&target);
let res = sanitize_create_path("nested_missing_parent/a/file.txt").await; assert_path_ok(
assert!(res.is_ok()); sanitize_create_path("nested_missing_parent/a/file.txt").await,
assert!(res.unwrap().ends_with("nested_missing_parent/a/file.txt")); "nested_missing_parent/a/file.txt",
);
} }
#[tokio::test] #[tokio::test]
async fn test_sanitize_create_path_traversal_denied() { async fn test_sanitize_create_path_traversal_denied() {
let _temp_workspace = init_test_workspace(); let _temp_workspace = init_test_workspace();
let res = sanitize_create_path("nested/../../../etc/passwd").await; assert_path_err(sanitize_create_path("nested/../../../etc/passwd").await);
assert!(res.is_err());
} }
#[cfg(unix)] #[cfg(unix)]
...@@ -253,9 +254,10 @@ mod tests { ...@@ -253,9 +254,10 @@ mod tests {
let _ = fs::remove_file(&link_path); let _ = fs::remove_file(&link_path);
std::os::unix::fs::symlink(outside.path(), &link_path).unwrap(); std::os::unix::fs::symlink(outside.path(), &link_path).unwrap();
let res = sanitize_existing_workspace_entry_path("move_source_link") let res = assert_path_ok(
.await sanitize_existing_workspace_entry_path("move_source_link").await,
.unwrap(); "move_source_link",
);
assert_eq!(res.file_name(), link_path.file_name()); assert_eq!(res.file_name(), link_path.file_name());
assert_ne!(res, outside.path()); assert_ne!(res, outside.path());
...@@ -270,7 +272,6 @@ mod tests { ...@@ -270,7 +272,6 @@ mod tests {
let _ = fs::remove_file(&link_path); let _ = fs::remove_file(&link_path);
std::os::unix::fs::symlink(outside.path(), &link_path).unwrap(); std::os::unix::fs::symlink(outside.path(), &link_path).unwrap();
let res = sanitize_create_path("outside_link_for_create/file.txt").await; assert_path_err(sanitize_create_path("outside_link_for_create/file.txt").await);
assert!(res.is_err());
} }
} }
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or sign in to comment