From 0a11ec65a2511daa7b783ac55e7269656ec9e450 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Tue, 28 Sep 2021 00:04:26 -0500 Subject: [PATCH] Add native ssh (#57) * Bump to 0.15.0 * Add new distant-ssh2 subcrate to provide an alternate session as an ssh client * Add rpassword & wezterm-ssh dependencies * Rename core -> distant-core in project directory structure and move ssh2 feature into distant-ssh2 crate * Upgrade tokio to 1.12, * Update github actions to detect changes and apply testing for only those changes * Add method parameter to support distant & ssh methods for action and lsp subcommands * Add ssh-host, ssh-port, and ssh-user parameters to specify information for ssh method --- .github/workflows/ci.yml | 76 +- .gitignore | 5 +- Cargo.lock | 642 +++++- Cargo.toml | 20 +- {core => distant-core}/Cargo.toml | 11 +- {core => distant-core}/README.md | 0 {core => distant-core}/src/client/lsp/data.rs | 0 {core => distant-core}/src/client/lsp/mod.rs | 0 {core => distant-core}/src/client/mod.rs | 0 {core => distant-core}/src/client/process.rs | 0 .../src/client/session/ext.rs | 0 .../src/client/session/info.rs | 0 .../src/client/session/mailbox.rs | 0 .../src/client/session/mod.rs | 0 {core => distant-core}/src/client/utils.rs | 0 {core => distant-core}/src/constants.rs | 0 {core => distant-core}/src/data.rs | 0 {core => distant-core}/src/lib.rs | 0 {core => distant-core}/src/net/listener.rs | 0 {core => distant-core}/src/net/mod.rs | 0 .../src/net/transport/codec/mod.rs | 0 .../src/net/transport/codec/plain.rs | 0 .../net/transport/codec/xchacha20poly1305.rs | 0 .../src/net/transport/inmemory.rs | 0 .../src/net/transport/mod.rs | 0 .../src/net/transport/tcp.rs | 0 .../src/net/transport/unix.rs | 0 .../src/server/distant/handler.rs | 95 +- .../src/server/distant/mod.rs | 0 .../src/server/distant/state.rs | 0 {core => distant-core}/src/server/mod.rs | 0 {core => distant-core}/src/server/port.rs | 0 {core => distant-core}/src/server/relay.rs | 0 {core => distant-core}/src/server/utils.rs | 0 distant-ssh2/Cargo.toml | 32 + distant-ssh2/src/handler.rs | 881 ++++++++ distant-ssh2/src/lib.rs | 255 +++ distant-ssh2/tests/lib.rs | 2 + distant-ssh2/tests/ssh2/mod.rs | 1 + distant-ssh2/tests/ssh2/session.rs | 1871 +++++++++++++++++ distant-ssh2/tests/sshd.rs | 432 ++++ src/constants.rs | 22 +- src/opt.rs | 81 +- src/subcommand/action.rs | 20 +- src/subcommand/lsp.rs | 20 +- src/subcommand/mod.rs | 228 +- tests/cli/action/proc_run.rs | 85 +- tests/cli/fixtures.rs | 7 +- tests/cli/utils.rs | 9 +- 49 files changed, 4564 insertions(+), 231 deletions(-) rename {core => distant-core}/Cargo.toml (80%) rename {core => distant-core}/README.md (100%) rename {core => distant-core}/src/client/lsp/data.rs (100%) rename {core => distant-core}/src/client/lsp/mod.rs (100%) rename {core => distant-core}/src/client/mod.rs (100%) rename {core => distant-core}/src/client/process.rs (100%) rename {core => distant-core}/src/client/session/ext.rs (100%) rename {core => distant-core}/src/client/session/info.rs (100%) rename {core => distant-core}/src/client/session/mailbox.rs (100%) rename {core => distant-core}/src/client/session/mod.rs (100%) rename {core => distant-core}/src/client/utils.rs (100%) rename {core => distant-core}/src/constants.rs (100%) rename {core => distant-core}/src/data.rs (100%) rename {core => distant-core}/src/lib.rs (100%) rename {core => distant-core}/src/net/listener.rs (100%) rename {core => distant-core}/src/net/mod.rs (100%) rename {core => distant-core}/src/net/transport/codec/mod.rs (100%) rename {core => distant-core}/src/net/transport/codec/plain.rs (100%) rename {core => distant-core}/src/net/transport/codec/xchacha20poly1305.rs (100%) rename {core => distant-core}/src/net/transport/inmemory.rs (100%) rename {core => distant-core}/src/net/transport/mod.rs (100%) rename {core => distant-core}/src/net/transport/tcp.rs (100%) rename {core => distant-core}/src/net/transport/unix.rs (100%) rename {core => distant-core}/src/server/distant/handler.rs (97%) rename {core => distant-core}/src/server/distant/mod.rs (100%) rename {core => distant-core}/src/server/distant/state.rs (100%) rename {core => distant-core}/src/server/mod.rs (100%) rename {core => distant-core}/src/server/port.rs (100%) rename {core => distant-core}/src/server/relay.rs (100%) rename {core => distant-core}/src/server/utils.rs (100%) create mode 100644 distant-ssh2/Cargo.toml create mode 100644 distant-ssh2/src/handler.rs create mode 100644 distant-ssh2/src/lib.rs create mode 100644 distant-ssh2/tests/lib.rs create mode 100644 distant-ssh2/tests/ssh2/mod.rs create mode 100644 distant-ssh2/tests/ssh2/session.rs create mode 100644 distant-ssh2/tests/sshd.rs diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 990b089..65a880e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,8 +9,8 @@ on: - master jobs: - test-core: - name: "[distant-core] Test Rust ${{ matrix.rust }} on ${{ matrix.os }}" + tests: + name: "Test Rust ${{ matrix.rust }} on ${{ matrix.os }}" runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -32,34 +32,52 @@ jobs: run: cargo --version - uses: Vampire/setup-wsl@v1 if: ${{ matrix.os == 'windows-latest' }} - - run: cargo test --verbose -p distant-core - - run: cargo test --verbose --all-features -p distant-core - - test-cli: - name: "[distant] Test Rust ${{ matrix.rust }} on ${{ matrix.os }}" - runs-on: ${{ matrix.os }} - strategy: - fail-fast: false - matrix: - include: - - { rust: stable, os: ubuntu-latest } - - { rust: stable, os: macos-latest } - # - { rust: stable, os: windows-latest } - - { rust: 1.51.0, os: ubuntu-latest } - steps: - - uses: actions/checkout@v2 - - name: Install Rust ${{ matrix.rust }} - uses: actions-rs/toolchain@v1 + - uses: dorny/paths-filter@v2 + id: changes with: - profile: minimal - toolchain: ${{ matrix.rust }} - components: rustfmt, clippy - - uses: Swatinem/rust-cache@v1 - - name: Check Cargo availability - run: cargo --version - - uses: Vampire/setup-wsl@v1 - if: ${{ matrix.os == 'windows-latest' }} - - run: cargo test --verbose + base: ${{ github.ref }} + filters: | + cli: + - 'src/**' + - 'Cargo.*' + core: + - 'distant-core/**' + ssh2: + - 'distant-ssh2/**' + - name: Run core tests (default features) + run: cargo test --verbose -p distant-core + if: steps.changes.outputs.core == 'true' + - name: Run core tests (all features) + run: cargo test --verbose --all-features -p distant-core + if: steps.changes.outputs.core == 'true' + - name: Ensure /run/sshd exists on Unix + run: mkdir -p /run/sshd + if: | + matrix.os != 'windows-latest' && + matrix.os != 'macos-latest' && + steps.changes.outputs.ssh2 == 'true' + - name: Run ssh2 tests (default features) + run: cargo test --verbose -p distant-ssh2 + if: | + matrix.os != 'windows-latest' && + steps.changes.outputs.ssh2 == 'true' + - name: Run ssh2 tests (all features) + run: cargo test --verbose --all-features -p distant-ssh2 + if: | + matrix.os != 'windows-latest' && + steps.changes.outputs.ssh2 == 'true' + - name: Run CLI tests + run: cargo test --verbose + shell: bash + if: | + matrix.os != 'windows-latest' && + steps.changes.outputs.cli == 'true' + - name: Run CLI tests (no default features) + run: cargo test --verbose --no-default-features + shell: bash + if: | + matrix.os != 'windows-latest' && + steps.changes.outputs.cli == 'true' clippy: name: Lint with clippy diff --git a/.gitignore b/.gitignore index c3f230f..b329048 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target -.DS_Store -/core/Cargo.lock +**/.DS_Store +/distant-core/Cargo.lock +/distant-ssh2/Cargo.lock diff --git a/Cargo.lock b/Cargo.lock index bc907e7..f177d7b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -29,13 +29,28 @@ dependencies = [ "winapi", ] +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + +[[package]] +name = "anyhow" +version = "1.0.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61604a8f862e1d5c3229fdd78f8b02c68dcf73a4c4b05fd636d12240aaa242c1" + [[package]] name = "assert_cmd" version = "2.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "54f002ce7d0c5e809ebb02be78fd503aeed4a511fd0fcaff6e6914cbdabbfa33" dependencies = [ - "bstr", + "bstr 0.2.16", "doc-comment", "predicates", "predicates-core", @@ -57,6 +72,131 @@ dependencies = [ "tempfile", ] +[[package]] +name = "async-channel" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2114d64672151c0c5eaa5e131ec84a74f06e1e559830dabba01ca30605d66319" +dependencies = [ + "concurrent-queue", + "event-listener", + "futures-core", +] + +[[package]] +name = "async-compat" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b48b4ff0c2026db683dea961cd8ea874737f56cffca86fa84415eaddc51c00d" +dependencies = [ + "futures-core", + "futures-io", + "once_cell", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-executor" +version = "1.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "871f9bb5e0a22eeb7e8cf16641feb87c9dc67032ccf8ff49e772eb9941d3a965" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "once_cell", + "slab", +] + +[[package]] +name = "async-fs" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b3ca4f8ff117c37c278a2f7415ce9be55560b846b5bc4412aaa5d29c1c3dae2" +dependencies = [ + "async-lock", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-io" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a811e6a479f2439f0c04038796b5cfb3d2ad56c230e0f2d3f7b04d68cfee607b" +dependencies = [ + "concurrent-queue", + "futures-lite", + "libc", + "log", + "once_cell", + "parking", + "polling", + "slab", + "socket2", + "waker-fn", + "winapi", +] + +[[package]] +name = "async-lock" +version = "2.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6a8ea61bf9947a1007c5cada31e647dbc77b103c679858150003ba697ea798b" +dependencies = [ + "event-listener", +] + +[[package]] +name = "async-net" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5373304df79b9b4395068fb080369ec7178608827306ce4d081cba51cac551df" +dependencies = [ + "async-io", + "blocking", + "futures-lite", +] + +[[package]] +name = "async-process" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b21b63ab5a0db0369deb913540af2892750e42d949faacc7a61495ac418a1692" +dependencies = [ + "async-io", + "blocking", + "cfg-if", + "event-listener", + "futures-lite", + "libc", + "once_cell", + "signal-hook", + "winapi", +] + +[[package]] +name = "async-task" +version = "4.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e91831deabf0d6d7ec49552e489aed63b7456a7a3c46cff62adad428110b0af0" + +[[package]] +name = "async_ossl" +version = "0.1.0" +source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +dependencies = [ + "openssl", +] + +[[package]] +name = "atomic-waker" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "065374052e7df7ee4047b1160cca5e1467a12351a40b3da123c870ba0b8eda2a" + [[package]] name = "atty" version = "0.2.14" @@ -74,12 +214,43 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cdb031dd78e28731d87d56cc8ffef4a8f36ca26c38fe2de700543e627f8a464a" +[[package]] +name = "base64" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "904dfeac50f3cdaba28fc6f57fdcddb75f49ed61346676a78c4ffe55877802fd" + [[package]] name = "bitflags" version = "1.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" +[[package]] +name = "blocking" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5e170dbede1f740736619b776d7251cb1b9095c435c34d8ca9f57fcd2f335e9" +dependencies = [ + "async-channel", + "async-task", + "atomic-waker", + "fastrand", + "futures-lite", + "once_cell", +] + +[[package]] +name = "bstr" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59604ece62a407dc9164732e5adea02467898954c3a5811fd2dc140af14ef15b" +dependencies = [ + "lazy_static", + "memchr", + "regex-automata", +] + [[package]] name = "bstr" version = "0.2.16" @@ -103,6 +274,18 @@ version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4872d67bab6358e59559027aa3b9157c53d9358c51423c17554809a8858e0f8" +[[package]] +name = "cache-padded" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "631ae5198c9be5e753e5cc215e1bd73c2b466a3565173db433f52bb9d3e66dba" + +[[package]] +name = "cc" +version = "1.0.70" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d26a6ce4b6a484fa3edb70f7efa6fc430fd2b87285fe8b84304fd0936faa0dc0" + [[package]] name = "cfg-if" version = "1.0.0" @@ -162,7 +345,7 @@ version = "2.33.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "37e58ac78573c40708d45522f0d80fa2f01cc4f9b4e2bf749807255454312002" dependencies = [ - "ansi_term", + "ansi_term 0.11.0", "atty", "bitflags", "strsim", @@ -171,6 +354,15 @@ dependencies = [ "vec_map", ] +[[package]] +name = "concurrent-queue" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30ed07550be01594c6026cff2a1d7fe9c8f683caa798e12b68694ac9e88286a3" +dependencies = [ + "cache-padded", +] + [[package]] name = "convert_case" version = "0.4.0" @@ -214,19 +406,41 @@ version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6184e33543162437515c2e2b48714794e37845ec9851711914eec9d308f6ebe8" +[[package]] +name = "dirs-next" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b98cf8ebf19c3d1b223e151f99a4f9f0690dca41414773390fc824184ac833e1" +dependencies = [ + "cfg-if", + "dirs-sys-next", +] + +[[package]] +name = "dirs-sys-next" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ebda144c4fe02d1f7ea1a7d9641b6fc6b580adcfa024ae48797ecdeb6825b4d" +dependencies = [ + "libc", + "redox_users", + "winapi", +] + [[package]] name = "distant" -version = "0.14.2" +version = "0.15.0" dependencies = [ "assert_cmd", "assert_fs", "derive_more", "distant-core", - "flexi_logger", + "distant-ssh2", + "flexi_logger 0.18.1", "fork", "indoc", - "lazy_static", "log", + "once_cell", "predicates", "rand", "rstest", @@ -239,7 +453,7 @@ dependencies = [ [[package]] name = "distant-core" -version = "0.14.2" +version = "0.15.0" dependencies = [ "assert_fs", "bytes", @@ -248,13 +462,15 @@ dependencies = [ "futures", "hex", "indoc", - "lazy_static", "log", + "once_cell", "predicates", "rand", + "rpassword", "serde", "serde_cbor", "serde_json", + "ssh2", "structopt", "strum", "tokio", @@ -262,6 +478,29 @@ dependencies = [ "walkdir", ] +[[package]] +name = "distant-ssh2" +version = "0.15.0" +dependencies = [ + "assert_cmd", + "assert_fs", + "async-compat", + "distant-core", + "flexi_logger 0.19.4", + "futures", + "indoc", + "log", + "once_cell", + "predicates", + "rand", + "rpassword", + "rstest", + "smol", + "tokio", + "wezterm-ssh", + "whoami", +] + [[package]] name = "doc-comment" version = "0.3.3" @@ -274,6 +513,43 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "event-listener" +version = "2.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7531096570974c3a9dcf9e4b8e1cede1ec26cf5046219fb3b9d897503b9be59" + +[[package]] +name = "fastrand" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b394ed3d285a429378d3b384b9eb1285267e7df4b166df24b7a6939a04dc392e" +dependencies = [ + "instant", +] + +[[package]] +name = "filedescriptor" +version = "0.8.1" +source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +dependencies = [ + "libc", + "thiserror", + "winapi", +] + +[[package]] +name = "filenamegen" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b2da6e8ef70499318bc50abd003fd66dbf6d8a46c23f9e90158f388a788976a" +dependencies = [ + "anyhow", + "bstr 0.1.4", + "regex", + "walkdir", +] + [[package]] name = "flexi_logger" version = "0.18.1" @@ -290,6 +566,23 @@ dependencies = [ "yansi", ] +[[package]] +name = "flexi_logger" +version = "0.19.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35627d78fcea84d52181369fe48fcfce56a2a18e29443dba23d4c24b650fb107" +dependencies = [ + "ansi_term 0.12.1", + "atty", + "chrono", + "glob", + "lazy_static", + "log", + "regex", + "rustversion", + "thiserror", +] + [[package]] name = "float-cmp" version = "0.9.0" @@ -305,6 +598,21 @@ version = "1.0.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" +[[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 = "fork" version = "0.1.18" @@ -362,6 +670,21 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "522de2a0fe3e380f1bc577ba0474108faf3f6b18321dbf60b3b9c39a75073377" +[[package]] +name = "futures-lite" +version = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7694489acd39452c77daa48516b894c153f192c3578d5a839b62c58099fcbf48" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "memchr", + "parking", + "pin-project-lite", + "waker-fn", +] + [[package]] name = "futures-macro" version = "0.3.17" @@ -442,7 +765,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "10463d9ff00a2a068db14231982f5132edebad0d7660cd956a1c30292dbcbfbd" dependencies = [ "aho-corasick", - "bstr", + "bstr 0.2.16", "fnv", "log", "regex", @@ -525,6 +848,15 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "ioctl-rs" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7970510895cee30b3e9128319f2cefd4bde883a39f38baa279567ba3a7eb97d" +dependencies = [ + "libc", +] + [[package]] name = "itertools" version = "0.10.1" @@ -561,6 +893,31 @@ version = "0.2.101" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3cb00336871be5ed2c8ed44b60ae9959dc5b9f08539422ed43f09e34ecaeba21" +[[package]] +name = "libssh2-sys" +version = "0.2.21" +source = "git+https://github.com/wez/ssh2-rs.git?branch=win32ssl#c65067040c97a0cf7f96c69d6fc87764a32c34ae" +dependencies = [ + "cc", + "libc", + "libz-sys", + "openssl-sys", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "libz-sys" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de5435b8549c16d423ed0c03dbaafe57cf6c3344744f1242520d59c9d8ecec66" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "lock_api" version = "0.4.5" @@ -663,6 +1020,49 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "624a8340c38c1b80fd549087862da4ba43e08858af025b236e509b6649fc13d5" +[[package]] +name = "openssl" +version = "0.10.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8d9facdb76fec0b73c406f125d44d86fdad818d66fef0531eec9233ca425ff4a" +dependencies = [ + "bitflags", + "cfg-if", + "foreign-types", + "libc", + "once_cell", + "openssl-sys", +] + +[[package]] +name = "openssl-src" +version = "111.16.0+1.1.1l" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ab2173f69416cf3ec12debb5823d244127d23a9b127d5a5189aa97c5fa2859f" +dependencies = [ + "cc", +] + +[[package]] +name = "openssl-sys" +version = "0.9.66" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1996d2d305e561b70d1ee0c53f1542833f4e1ac6ce9a6708b6ff2738ca67dc82" +dependencies = [ + "autocfg", + "cc", + "libc", + "openssl-src", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "parking" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "427c3892f9e783d91cc128285287e70a59e206ca452770ece88a76f7a3eddd72" + [[package]] name = "parking_lot" version = "0.11.2" @@ -700,6 +1100,25 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" +[[package]] +name = "pkg-config" +version = "0.3.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3831453b3449ceb48b6d9c7ad7c96d5ea673e9b470a1dc578c2ce6521230884c" + +[[package]] +name = "polling" +version = "2.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92341d779fa34ea8437ef4d82d440d5e1ce3f3ff7f824aa64424cd481f9a1f25" +dependencies = [ + "cfg-if", + "libc", + "log", + "wepoll-ffi", + "winapi", +] + [[package]] name = "poly1305" version = "0.7.2" @@ -711,6 +1130,23 @@ dependencies = [ "universal-hash", ] +[[package]] +name = "portable-pty" +version = "0.5.0" +source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +dependencies = [ + "anyhow", + "bitflags", + "filedescriptor", + "lazy_static", + "libc", + "log", + "serial", + "shared_library", + "shell-words", + "winapi", +] + [[package]] name = "ppv-lite86" version = "0.2.10" @@ -850,6 +1286,16 @@ dependencies = [ "bitflags", ] +[[package]] +name = "redox_users" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528532f3d801c87aec9def2add9ca802fe569e44a544afe633765267840abe64" +dependencies = [ + "getrandom", + "redox_syscall", +] + [[package]] name = "regex" version = "1.5.4" @@ -882,6 +1328,16 @@ dependencies = [ "winapi", ] +[[package]] +name = "rpassword" +version = "5.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ffc936cf8a7ea60c58f030fd36a612a48f440610214dc54bc36431f9ea0c3efb" +dependencies = [ + "libc", + "winapi", +] + [[package]] name = "rstest" version = "0.11.0" @@ -904,6 +1360,12 @@ dependencies = [ "semver", ] +[[package]] +name = "rustversion" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "61b3909d758bb75c79f23d4736fac9433868679d3ad2ea7a61e3c25cfda9a088" + [[package]] name = "ryu" version = "1.0.5" @@ -972,6 +1434,74 @@ dependencies = [ "serde", ] +[[package]] +name = "serial" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1237a96570fc377c13baa1b88c7589ab66edced652e43ffb17088f003db3e86" +dependencies = [ + "serial-core", + "serial-unix", + "serial-windows", +] + +[[package]] +name = "serial-core" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f46209b345401737ae2125fe5b19a77acce90cd53e1658cda928e4fe9a64581" +dependencies = [ + "libc", +] + +[[package]] +name = "serial-unix" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f03fbca4c9d866e24a459cbca71283f545a37f8e3e002ad8c70593871453cab7" +dependencies = [ + "ioctl-rs", + "libc", + "serial-core", + "termios", +] + +[[package]] +name = "serial-windows" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15c6d3b776267a75d31bbdfd5d36c0ca051251caafc285827052bc53bcdc8162" +dependencies = [ + "libc", + "serial-core", +] + +[[package]] +name = "shared_library" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a9e7e0f2bfae24d8a5b5a66c5b257a83c7412304311512a0c054cd5e619da11" +dependencies = [ + "lazy_static", + "libc", +] + +[[package]] +name = "shell-words" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fa3938c99da4914afedd13bf3d79bcb6c277d1b2c398d23257a304d9e1b074" + +[[package]] +name = "signal-hook" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c98891d737e271a2954825ef19e46bd16bdb98e2746f2eec4f7a4ef7946efd1" +dependencies = [ + "libc", + "signal-hook-registry", +] + [[package]] name = "signal-hook-registry" version = "1.4.0" @@ -993,6 +1523,45 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fe0f37c9e8f3c5a4a66ad655a93c74daac4ad00c441533bf5c6e7990bb42604e" +[[package]] +name = "smol" +version = "1.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85cf3b5351f3e783c1d79ab5fc604eeed8b8ae9abd36b166e8b87a089efd85e4" +dependencies = [ + "async-channel", + "async-executor", + "async-fs", + "async-io", + "async-lock", + "async-net", + "async-process", + "blocking", + "futures-lite", + "once_cell", +] + +[[package]] +name = "socket2" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5dc90fe6c7be1a323296982db1836d1ea9e47b6839496dde9a541bc496df3516" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "ssh2" +version = "0.9.1" +source = "git+https://github.com/wez/ssh2-rs.git?branch=win32ssl#c65067040c97a0cf7f96c69d6fc87764a32c34ae" +dependencies = [ + "bitflags", + "libc", + "libssh2-sys", + "parking_lot", +] + [[package]] name = "strsim" version = "0.8.0" @@ -1075,6 +1644,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termios" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5d9cf598a6d7ce700a4e6a9199da127e6819a61e64b68609683cc9a01b5683a" +dependencies = [ + "libc", +] + [[package]] name = "textwrap" version = "0.11.0" @@ -1125,9 +1703,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.11.0" +version = "1.12.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4efe6fc2395938c8155973d7be49fe8d03a843726e285e100a8a383cc0154ce" +checksum = "c2c2416fdedca8443ae44b4527de1ea633af61d8f7169ffa6e72c5b53d24efcc" dependencies = [ "autocfg", "bytes", @@ -1214,6 +1792,12 @@ dependencies = [ "subtle", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "vec_map" version = "0.8.2" @@ -1235,6 +1819,12 @@ dependencies = [ "libc", ] +[[package]] +name = "waker-fn" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d5b2c62b4012a3e1eca5a7e077d13b3bf498c4073e33ccd58626607748ceeca" + [[package]] name = "walkdir" version = "2.3.2" @@ -1316,11 +1906,39 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wepoll-ffi" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d743fdedc5c64377b5fc2bc036b01c7fd642205a0d96356034ae3404d49eb7fb" +dependencies = [ + "cc", +] + +[[package]] +name = "wezterm-ssh" +version = "0.2.0" +source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +dependencies = [ + "anyhow", + "async_ossl", + "base64", + "dirs-next", + "filedescriptor", + "filenamegen", + "log", + "portable-pty", + "regex", + "smol", + "ssh2", + "thiserror", +] + [[package]] name = "whoami" -version = "1.1.3" +version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f7741161a40200a867c96dfa5574544efa4178cf4c8f770b62dd1cc0362d7ae1" +checksum = "cabfe22aa4936611957e0b5ad9ed0472ac52b2bfb9aedac4a3f3a91a03bd1ff0" dependencies = [ "wasm-bindgen", "web-sys", diff --git a/Cargo.toml b/Cargo.toml index 2c32972..fe5bf2f 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,7 +3,7 @@ name = "distant" description = "Operate on a remote computer through file and process manipulation" categories = ["command-line-utilities"] keywords = ["cli"] -version = "0.14.2" +version = "0.15.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" @@ -12,27 +12,37 @@ readme = "README.md" license = "MIT OR Apache-2.0" [workspace] -members = ["core"] +members = ["distant-core", "distant-ssh2"] [profile.release] opt-level = 'z' lto = true codegen-units = 1 +[patch.crates-io] +ssh2 = { git = "https://github.com/wez/ssh2-rs.git", branch="win32ssl" } + +[features] +default = ["ssh2"] +ssh2 = ["distant-ssh2"] + [dependencies] derive_more = { version = "0.99.16", default-features = false, features = ["display", "from", "error", "is_variant"] } -distant-core = { version = "=0.14.2", path = "core", features = ["structopt"] } +distant-core = { version = "=0.15.0", path = "distant-core", features = ["structopt"] } flexi_logger = "0.18.0" fork = "0.1.18" -lazy_static = "1.4.0" log = "0.4.14" +once_cell = "1.8.0" rand = { version = "0.8.4", features = ["getrandom"] } -tokio = { version = "1.9.0", features = ["full"] } +tokio = { version = "1.12.0", features = ["full"] } serde_json = "1.0.64" structopt = "0.3.22" strum = { version = "0.21.0", features = ["derive"] } whoami = "1.1.2" +# Optional native SSH functionality +distant-ssh2 = { version = "=0.15.0", path = "distant-ssh2", optional = true } + [dev-dependencies] assert_cmd = "2.0.0" assert_fs = "1.0.4" diff --git a/core/Cargo.toml b/distant-core/Cargo.toml similarity index 80% rename from core/Cargo.toml rename to distant-core/Cargo.toml index f339a3f..cdcbaa0 100644 --- a/core/Cargo.toml +++ b/distant-core/Cargo.toml @@ -2,7 +2,7 @@ name = "distant-core" description = "Core library for distant, enabling operation on a remote computer through file and process manipulation" categories = ["network-programming"] -version = "0.14.2" +version = "0.15.0" authors = ["Chip Senkbeil "] edition = "2018" homepage = "https://github.com/chipsenkbeil/distant" @@ -10,24 +10,29 @@ repository = "https://github.com/chipsenkbeil/distant" readme = "README.md" license = "MIT OR Apache-2.0" +[features] +native-ssh2 = ["rpassword", "ssh2"] + [dependencies] bytes = "1.1.0" chacha20poly1305 = "0.9.0" derive_more = { version = "0.99.16", default-features = false, features = ["display", "from", "error", "is_variant"] } futures = "0.3.16" hex = "0.4.3" -lazy_static = "1.4.0" log = "0.4.14" +once_cell = "1.8.0" rand = { version = "0.8.4", features = ["getrandom"] } serde = { version = "1.0.126", features = ["derive"] } serde_cbor = "0.11.1" serde_json = "1.0.64" strum = { version = "0.21.0", features = ["derive"] } -tokio = { version = "1.9.0", features = ["full"] } +tokio = { version = "1.12.0", features = ["full"] } tokio-util = { version = "0.6.7", features = ["codec"] } walkdir = "2.3.2" # Optional dependencies based on features +rpassword = { version = "5.0.1", optional = true } +ssh2 = { version = "0.9.1", features = ["vendored-openssl"], optional = true } structopt = { version = "0.3.22", optional = true } [dev-dependencies] diff --git a/core/README.md b/distant-core/README.md similarity index 100% rename from core/README.md rename to distant-core/README.md diff --git a/core/src/client/lsp/data.rs b/distant-core/src/client/lsp/data.rs similarity index 100% rename from core/src/client/lsp/data.rs rename to distant-core/src/client/lsp/data.rs diff --git a/core/src/client/lsp/mod.rs b/distant-core/src/client/lsp/mod.rs similarity index 100% rename from core/src/client/lsp/mod.rs rename to distant-core/src/client/lsp/mod.rs diff --git a/core/src/client/mod.rs b/distant-core/src/client/mod.rs similarity index 100% rename from core/src/client/mod.rs rename to distant-core/src/client/mod.rs diff --git a/core/src/client/process.rs b/distant-core/src/client/process.rs similarity index 100% rename from core/src/client/process.rs rename to distant-core/src/client/process.rs diff --git a/core/src/client/session/ext.rs b/distant-core/src/client/session/ext.rs similarity index 100% rename from core/src/client/session/ext.rs rename to distant-core/src/client/session/ext.rs diff --git a/core/src/client/session/info.rs b/distant-core/src/client/session/info.rs similarity index 100% rename from core/src/client/session/info.rs rename to distant-core/src/client/session/info.rs diff --git a/core/src/client/session/mailbox.rs b/distant-core/src/client/session/mailbox.rs similarity index 100% rename from core/src/client/session/mailbox.rs rename to distant-core/src/client/session/mailbox.rs diff --git a/core/src/client/session/mod.rs b/distant-core/src/client/session/mod.rs similarity index 100% rename from core/src/client/session/mod.rs rename to distant-core/src/client/session/mod.rs diff --git a/core/src/client/utils.rs b/distant-core/src/client/utils.rs similarity index 100% rename from core/src/client/utils.rs rename to distant-core/src/client/utils.rs diff --git a/core/src/constants.rs b/distant-core/src/constants.rs similarity index 100% rename from core/src/constants.rs rename to distant-core/src/constants.rs diff --git a/core/src/data.rs b/distant-core/src/data.rs similarity index 100% rename from core/src/data.rs rename to distant-core/src/data.rs diff --git a/core/src/lib.rs b/distant-core/src/lib.rs similarity index 100% rename from core/src/lib.rs rename to distant-core/src/lib.rs diff --git a/core/src/net/listener.rs b/distant-core/src/net/listener.rs similarity index 100% rename from core/src/net/listener.rs rename to distant-core/src/net/listener.rs diff --git a/core/src/net/mod.rs b/distant-core/src/net/mod.rs similarity index 100% rename from core/src/net/mod.rs rename to distant-core/src/net/mod.rs diff --git a/core/src/net/transport/codec/mod.rs b/distant-core/src/net/transport/codec/mod.rs similarity index 100% rename from core/src/net/transport/codec/mod.rs rename to distant-core/src/net/transport/codec/mod.rs diff --git a/core/src/net/transport/codec/plain.rs b/distant-core/src/net/transport/codec/plain.rs similarity index 100% rename from core/src/net/transport/codec/plain.rs rename to distant-core/src/net/transport/codec/plain.rs diff --git a/core/src/net/transport/codec/xchacha20poly1305.rs b/distant-core/src/net/transport/codec/xchacha20poly1305.rs similarity index 100% rename from core/src/net/transport/codec/xchacha20poly1305.rs rename to distant-core/src/net/transport/codec/xchacha20poly1305.rs diff --git a/core/src/net/transport/inmemory.rs b/distant-core/src/net/transport/inmemory.rs similarity index 100% rename from core/src/net/transport/inmemory.rs rename to distant-core/src/net/transport/inmemory.rs diff --git a/core/src/net/transport/mod.rs b/distant-core/src/net/transport/mod.rs similarity index 100% rename from core/src/net/transport/mod.rs rename to distant-core/src/net/transport/mod.rs diff --git a/core/src/net/transport/tcp.rs b/distant-core/src/net/transport/tcp.rs similarity index 100% rename from core/src/net/transport/tcp.rs rename to distant-core/src/net/transport/tcp.rs diff --git a/core/src/net/transport/unix.rs b/distant-core/src/net/transport/unix.rs similarity index 100% rename from core/src/net/transport/unix.rs rename to distant-core/src/net/transport/unix.rs diff --git a/core/src/server/distant/handler.rs b/distant-core/src/server/distant/handler.rs similarity index 97% rename from core/src/server/distant/handler.rs rename to distant-core/src/server/distant/handler.rs index 918dbd1..ad672bf 100644 --- a/core/src/server/distant/handler.rs +++ b/distant-core/src/server/distant/handler.rs @@ -591,6 +591,12 @@ where error!(" Join on stdout task failed: {}", conn_id, x); } + // Wait for the child after being killed to ensure that it has been cleaned + // up at the operating system level + if let Err(x) = child.wait().await { + error!(" Failed to wait on killed process {}: {}", conn_id, id, x); + } + state_2.lock().await.remove_process(conn_id, id); let payload = vec![ResponseData::ProcDone { id, success: false, code: None }]; @@ -669,61 +675,68 @@ async fn system_info() -> Result { mod tests { use super::*; use assert_fs::prelude::*; + use once_cell::sync::Lazy; use predicates::prelude::*; use std::time::Duration; - lazy_static::lazy_static! { - static ref TEMP_SCRIPT_DIR: assert_fs::TempDir = assert_fs::TempDir::new().unwrap(); - static ref SCRIPT_RUNNER: String = String::from("bash"); + static TEMP_SCRIPT_DIR: Lazy = + Lazy::new(|| assert_fs::TempDir::new().unwrap()); + static SCRIPT_RUNNER: Lazy = Lazy::new(|| String::from("bash")); - static ref ECHO_ARGS_TO_STDOUT_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); - script.write_str(indoc::indoc!(r#" + static ECHO_ARGS_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash - printf "%s" "$@" - "#)).unwrap(); - script - }; + printf "%s" "$*" + "# + )) + .unwrap(); + script + }); - static ref ECHO_ARGS_TO_STDERR_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); - script.write_str(indoc::indoc!(r#" + static ECHO_ARGS_TO_STDERR_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash - printf "%s" "$@" 1>&2 - "#)).unwrap(); - script - }; + printf "%s" "$*" 1>&2 + "# + )) + .unwrap(); + script + }); - static ref ECHO_STDIN_TO_STDOUT_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); - script.write_str(indoc::indoc!(r#" + static ECHO_STDIN_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash while IFS= read; do echo "$REPLY"; done - "#)).unwrap(); - script - }; - - static ref EXIT_CODE_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("exit_code.sh"); - script.write_str(indoc::indoc!(r#" - #!/usr/bin/env bash - exit "$1" - "#)).unwrap(); - script - }; + "# + )) + .unwrap(); + script + }); - static ref SLEEP_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("sleep.sh"); - script.write_str(indoc::indoc!(r#" + static SLEEP_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("sleep.sh"); + script + .write_str(indoc::indoc!( + r#" #!/usr/bin/env bash sleep "$1" - "#)).unwrap(); - script - }; + "# + )) + .unwrap(); + script + }); - static ref DOES_NOT_EXIST_BIN: assert_fs::fixture::ChildPath = - TEMP_SCRIPT_DIR.child("does_not_exist_bin"); - } + static DOES_NOT_EXIST_BIN: Lazy = + Lazy::new(|| TEMP_SCRIPT_DIR.child("does_not_exist_bin")); fn setup( buffer: usize, diff --git a/core/src/server/distant/mod.rs b/distant-core/src/server/distant/mod.rs similarity index 100% rename from core/src/server/distant/mod.rs rename to distant-core/src/server/distant/mod.rs diff --git a/core/src/server/distant/state.rs b/distant-core/src/server/distant/state.rs similarity index 100% rename from core/src/server/distant/state.rs rename to distant-core/src/server/distant/state.rs diff --git a/core/src/server/mod.rs b/distant-core/src/server/mod.rs similarity index 100% rename from core/src/server/mod.rs rename to distant-core/src/server/mod.rs diff --git a/core/src/server/port.rs b/distant-core/src/server/port.rs similarity index 100% rename from core/src/server/port.rs rename to distant-core/src/server/port.rs diff --git a/core/src/server/relay.rs b/distant-core/src/server/relay.rs similarity index 100% rename from core/src/server/relay.rs rename to distant-core/src/server/relay.rs diff --git a/core/src/server/utils.rs b/distant-core/src/server/utils.rs similarity index 100% rename from core/src/server/utils.rs rename to distant-core/src/server/utils.rs diff --git a/distant-ssh2/Cargo.toml b/distant-ssh2/Cargo.toml new file mode 100644 index 0000000..3df6226 --- /dev/null +++ b/distant-ssh2/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "distant-ssh2" +description = "Library to enable native ssh-2 protocol for use with distant sessions" +categories = ["network-programming"] +version = "0.15.0" +authors = ["Chip Senkbeil "] +edition = "2018" +homepage = "https://github.com/chipsenkbeil/distant" +repository = "https://github.com/chipsenkbeil/distant" +readme = "README.md" +license = "MIT OR Apache-2.0" + +[dependencies] +async-compat = "0.2.1" +distant-core = { version = "=0.15.0", path = "../distant-core" } +futures = "0.3.16" +log = "0.4.14" +rand = { version = "0.8.4", features = ["getrandom"] } +rpassword = "5.0.1" +smol = "1.2" +tokio = { version = "1.12.0", features = ["full"] } +wezterm-ssh = { version = "0.2.0", features = ["vendored-openssl"], git = "https://github.com/chipsenkbeil/wezterm" } + +[dev-dependencies] +assert_cmd = "2.0.0" +assert_fs = "1.0.4" +flexi_logger = "0.19.4" +indoc = "1.0.3" +once_cell = "1.8.0" +predicates = "2.0.2" +rstest = "0.11.0" +whoami = "1.1.4" diff --git a/distant-ssh2/src/handler.rs b/distant-ssh2/src/handler.rs new file mode 100644 index 0000000..d8caeb3 --- /dev/null +++ b/distant-ssh2/src/handler.rs @@ -0,0 +1,881 @@ +use async_compat::CompatExt; +use distant_core::{ + data::{DirEntry, Error as DistantError, FileType, RunningProcess}, + Request, RequestData, Response, ResponseData, +}; +use futures::future; +use log::*; +use std::{ + collections::HashMap, + future::Future, + io::{self, Read, Write}, + path::{Component, Path, PathBuf}, + pin::Pin, + sync::Arc, +}; +use tokio::sync::{mpsc, Mutex}; +use wezterm_ssh::{Child, ExecResult, OpenFileType, OpenOptions, Session as WezSession, WriteMode}; + +const MAX_PIPE_CHUNK_SIZE: usize = 8192; +const READ_PAUSE_MILLIS: u64 = 50; + +fn to_other_error(err: E) -> io::Error +where + E: Into>, +{ + io::Error::new(io::ErrorKind::Other, err) +} + +#[derive(Default)] +pub(crate) struct State { + processes: HashMap, +} + +struct Process { + id: usize, + cmd: String, + args: Vec, + stdin_tx: mpsc::Sender, + kill_tx: mpsc::Sender<()>, +} + +type ReplyRet = Pin + Send + 'static>>; + +type PostHook = Box; +struct Outgoing { + data: ResponseData, + post_hook: Option, +} + +impl From for Outgoing { + fn from(data: ResponseData) -> Self { + Self { + data, + post_hook: None, + } + } +} + +/// Processes the provided request, sending replies using the given sender +pub(super) async fn process( + session: WezSession, + state: Arc>, + req: Request, + tx: mpsc::Sender, +) -> Result<(), mpsc::error::SendError> { + async fn inner( + session: WezSession, + state: Arc>, + data: RequestData, + reply: F, + ) -> io::Result + where + F: FnMut(Vec) -> ReplyRet + Clone + Send + 'static, + { + match data { + RequestData::FileRead { path } => file_read(session, path).await, + RequestData::FileReadText { path } => file_read_text(session, path).await, + RequestData::FileWrite { path, data } => file_write(session, path, data).await, + RequestData::FileWriteText { path, text } => file_write(session, path, text).await, + RequestData::FileAppend { path, data } => file_append(session, path, data).await, + RequestData::FileAppendText { path, text } => file_append(session, path, text).await, + RequestData::DirRead { + path, + depth, + absolute, + canonicalize, + include_root, + } => dir_read(session, path, depth, absolute, canonicalize, include_root).await, + RequestData::DirCreate { path, all } => dir_create(session, path, all).await, + RequestData::Remove { path, force } => remove(session, path, force).await, + RequestData::Copy { src, dst } => copy(session, src, dst).await, + RequestData::Rename { src, dst } => rename(session, src, dst).await, + RequestData::Exists { path } => exists(session, path).await, + RequestData::Metadata { + path, + canonicalize, + resolve_file_type, + } => metadata(session, path, canonicalize, resolve_file_type).await, + RequestData::ProcRun { cmd, args } => proc_run(session, state, reply, cmd, args).await, + RequestData::ProcKill { id } => proc_kill(session, state, id).await, + RequestData::ProcStdin { id, data } => proc_stdin(session, state, id, data).await, + RequestData::ProcList {} => proc_list(session, state).await, + RequestData::SystemInfo {} => system_info(session).await, + } + } + + let reply = { + let origin_id = req.id; + let tenant = req.tenant.clone(); + let tx_2 = tx.clone(); + move |payload: Vec| -> ReplyRet { + let tx = tx_2.clone(); + let res = Response::new(tenant.to_string(), origin_id, payload); + Box::pin(async move { tx.send(res).await.is_ok() }) + } + }; + + // Build up a collection of tasks to run independently + let mut payload_tasks = Vec::new(); + for data in req.payload { + let state_2 = Arc::clone(&state); + let reply_2 = reply.clone(); + let session = session.clone(); + payload_tasks.push(tokio::spawn(async move { + match inner(session, state_2, data, reply_2).await { + Ok(outgoing) => outgoing, + Err(x) => Outgoing::from(ResponseData::from(x)), + } + })); + } + + // Collect the results of our tasks into the payload entries + let mut outgoing: Vec = future::join_all(payload_tasks) + .await + .into_iter() + .map(|x| match x { + Ok(outgoing) => outgoing, + Err(x) => Outgoing::from(ResponseData::from(x)), + }) + .collect(); + + let post_hooks: Vec = outgoing + .iter_mut() + .filter_map(|x| x.post_hook.take()) + .collect(); + + let payload = outgoing.into_iter().map(|x| x.data).collect(); + let res = Response::new(req.tenant, req.id, payload); + // Send out our primary response from processing the request + let result = tx.send(res).await; + + // Invoke all post hooks + for hook in post_hooks { + hook(); + } + + result +} + +async fn file_read(session: WezSession, path: PathBuf) -> io::Result { + use smol::io::AsyncReadExt; + let mut file = session + .sftp() + .open(path) + .compat() + .await + .map_err(to_other_error)?; + + let mut contents = String::new(); + file.read_to_string(&mut contents).compat().await?; + + Ok(Outgoing::from(ResponseData::Blob { + data: contents.into_bytes(), + })) +} + +async fn file_read_text(session: WezSession, path: PathBuf) -> io::Result { + use smol::io::AsyncReadExt; + let mut file = session + .sftp() + .open(path) + .compat() + .await + .map_err(to_other_error)?; + + let mut contents = String::new(); + file.read_to_string(&mut contents).compat().await?; + + Ok(Outgoing::from(ResponseData::Text { data: contents })) +} + +async fn file_write( + session: WezSession, + path: PathBuf, + data: impl AsRef<[u8]>, +) -> io::Result { + use smol::io::AsyncWriteExt; + let mut file = session + .sftp() + .create(path) + .compat() + .await + .map_err(to_other_error)?; + + file.write_all(data.as_ref()).compat().await?; + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn file_append( + session: WezSession, + path: PathBuf, + data: impl AsRef<[u8]>, +) -> io::Result { + use smol::io::AsyncWriteExt; + let mut file = session + .sftp() + .open_mode( + path, + OpenOptions { + read: false, + write: Some(WriteMode::Append), + // Using 644 as this mirrors "ssh touch ..." + // 644: rw-r--r-- + mode: 0o644, + ty: OpenFileType::File, + }, + ) + .compat() + .await + .map_err(to_other_error)?; + + file.write_all(data.as_ref()).compat().await?; + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn dir_read( + session: WezSession, + path: PathBuf, + depth: usize, + absolute: bool, + canonicalize: bool, + include_root: bool, +) -> io::Result { + let sftp = session.sftp(); + + // Canonicalize our provided path to ensure that it is exists, not a loop, and absolute + let root_path = sftp.realpath(path).compat().await.map_err(to_other_error)?; + + // Build up our entry list + let mut entries = Vec::new(); + let mut errors = Vec::new(); + + let mut to_traverse = vec![DirEntry { + path: root_path.to_path_buf(), + file_type: FileType::Dir, + depth: 0, + }]; + + while let Some(entry) = to_traverse.pop() { + let is_root = entry.depth == 0; + let next_depth = entry.depth + 1; + let ft = entry.file_type; + let path = if entry.path.is_relative() { + root_path.join(&entry.path) + } else { + entry.path.to_path_buf() + }; + + // Always include any non-root in our traverse list, but only include the + // root directory if flagged to do so + if !is_root || include_root { + entries.push(entry); + } + + let is_dir = match ft { + FileType::Dir => true, + FileType::File => false, + FileType::Symlink => match sftp.stat(&path).await { + Ok(stat) => stat.is_dir(), + Err(x) => { + errors.push(DistantError::from(to_other_error(x))); + continue; + } + }, + }; + + // Determine if we continue traversing or stop + if is_dir && (depth == 0 || next_depth <= depth) { + match sftp.readdir(&path).compat().await.map_err(to_other_error) { + Ok(entries) => { + for (mut path, stat) in entries { + // Canonicalize the path if specified, otherwise just return + // the path as is + path = if canonicalize { + match sftp.realpath(path).compat().await { + Ok(path) => path, + Err(x) => { + errors.push(DistantError::from(to_other_error(x))); + continue; + } + } + } else { + path + }; + + // Strip the path of its prefix based if not flagged as absolute + if !absolute { + // NOTE: In the situation where we canonicalized the path earlier, + // there is no guarantee that our root path is still the parent of + // the symlink's destination; so, in that case we MUST just return + // the path if the strip_prefix fails + path = path + .strip_prefix(root_path.as_path()) + .map(Path::to_path_buf) + .unwrap_or(path); + }; + + let ft = stat.ty; + to_traverse.push(DirEntry { + path, + file_type: if ft.is_dir() { + FileType::Dir + } else if ft.is_file() { + FileType::File + } else { + FileType::Symlink + }, + depth: next_depth, + }); + } + } + Err(x) if is_root => return Err(io::Error::new(io::ErrorKind::Other, x)), + Err(x) => errors.push(DistantError::from(x)), + } + } + } + + // Sort entries by filename + entries.sort_unstable_by_key(|e| e.path.to_path_buf()); + + Ok(Outgoing::from(ResponseData::DirEntries { entries, errors })) +} + +async fn dir_create(session: WezSession, path: PathBuf, all: bool) -> io::Result { + let sftp = session.sftp(); + + // Makes the immediate directory, failing if given a path with missing components + async fn mkdir(sftp: &wezterm_ssh::Sftp, path: PathBuf) -> io::Result<()> { + // Using 755 as this mirrors "ssh mkdir ..." + // 755: rwxr-xr-x + sftp.mkdir(path, 0o755) + .compat() + .await + .map_err(to_other_error) + } + + if all { + // Keep trying to create a directory, moving up to parent each time a failure happens + let mut failed_paths = Vec::new(); + let mut cur_path = path.as_path(); + loop { + let failed = mkdir(&sftp, cur_path.to_path_buf()).await.is_err(); + if failed { + failed_paths.push(cur_path); + if let Some(path) = cur_path.parent() { + cur_path = path; + } else { + return Err(io::Error::from(io::ErrorKind::PermissionDenied)); + } + } else { + break; + } + } + + // Now that we've successfully created a parent component (or the directory), proceed + // to attempt to create each failed directory + while let Some(path) = failed_paths.pop() { + mkdir(&sftp, path.to_path_buf()).await?; + } + } else { + mkdir(&sftp, path).await?; + } + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn remove(session: WezSession, path: PathBuf, force: bool) -> io::Result { + let sftp = session.sftp(); + + // Determine if we are dealing with a file or directory + let stat = sftp + .stat(path.to_path_buf()) + .compat() + .await + .map_err(to_other_error)?; + + // If a file or symlink, we just unlink (easy) + if stat.is_file() || stat.is_symlink() { + sftp.unlink(path) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::PermissionDenied, x))?; + // If directory and not forcing, we just rmdir (easy) + } else if !force { + sftp.rmdir(path) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::PermissionDenied, x))?; + // Otherwise, we need to find all files and directories, keep track of their depth, and + // then attempt to remove them all + } else { + let mut entries = Vec::new(); + let mut to_traverse = vec![DirEntry { + path, + file_type: FileType::Dir, + depth: 0, + }]; + + // Collect all entries within directory + while let Some(entry) = to_traverse.pop() { + if entry.file_type == FileType::Dir { + let path = entry.path.to_path_buf(); + let depth = entry.depth; + + entries.push(entry); + + for (path, stat) in sftp.readdir(path).await.map_err(to_other_error)? { + to_traverse.push(DirEntry { + path, + file_type: if stat.is_dir() { + FileType::Dir + } else if stat.is_file() { + FileType::File + } else { + FileType::Symlink + }, + depth: depth + 1, + }); + } + } else { + entries.push(entry); + } + } + + // Sort by depth such that deepest are last as we will be popping + // off entries from end to remove first + entries.sort_unstable_by_key(|e| e.depth); + + while let Some(entry) = entries.pop() { + if entry.file_type == FileType::Dir { + sftp.rmdir(entry.path) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::PermissionDenied, x))?; + } else { + sftp.unlink(entry.path) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::PermissionDenied, x))?; + } + } + } + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn copy(session: WezSession, src: PathBuf, dst: PathBuf) -> io::Result { + // NOTE: SFTP does not provide a remote-to-remote copy method, so we instead execute + // a program and hope that it applies, starting with the Unix/BSD/GNU cp method + // and switch to Window's xcopy if the former fails + + // Unix cp -R + let unix_result = session + .exec(&format!("cp -R {:?} {:?}", src, dst), None) + .compat() + .await; + + let failed = unix_result.is_err() || { + let exit_status = unix_result.unwrap().child.async_wait().compat().await; + exit_status.is_err() || !exit_status.unwrap().success() + }; + + // Windows xcopy /s /e + if failed { + let exit_status = session + .exec(&format!("xcopy {:?} {:?} /s /e", src, dst), None) + .compat() + .await + .map_err(to_other_error)? + .child + .async_wait() + .compat() + .await + .map_err(to_other_error)?; + + if !exit_status.success() { + return Err(io::Error::new( + io::ErrorKind::Other, + "Unix and windows copy commands failed", + )); + } + } + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn rename(session: WezSession, src: PathBuf, dst: PathBuf) -> io::Result { + session + .sftp() + .rename(src, dst, Default::default()) + .compat() + .await + .map_err(to_other_error)?; + + Ok(Outgoing::from(ResponseData::Ok)) +} + +async fn exists(session: WezSession, path: PathBuf) -> io::Result { + // NOTE: SFTP does not provide a means to check if a path exists that can be performed + // separately from getting permission errors; so, we just assume any error means that the path + // does not exist + let exists = session.sftp().lstat(path).compat().await.is_ok(); + + Ok(Outgoing::from(ResponseData::Exists(exists))) +} + +async fn metadata( + session: WezSession, + path: PathBuf, + canonicalize: bool, + resolve_file_type: bool, +) -> io::Result { + let sftp = session.sftp(); + let canonicalized_path = if canonicalize { + Some( + sftp.realpath(path.to_path_buf()) + .compat() + .await + .map_err(to_other_error)?, + ) + } else { + None + }; + + let stat = if resolve_file_type { + sftp.stat(path).compat().await.map_err(to_other_error)? + } else { + sftp.lstat(path).compat().await.map_err(to_other_error)? + }; + + let file_type = if stat.is_dir() { + FileType::Dir + } else if stat.is_file() { + FileType::File + } else { + FileType::Symlink + }; + + Ok(Outgoing::from(ResponseData::Metadata { + canonicalized_path, + file_type, + len: stat.len(), + // Check that owner, group, or other has write permission (if not, then readonly) + readonly: stat.is_readonly(), + accessed: stat.accessed.map(u128::from), + modified: stat.modified.map(u128::from), + created: None, + })) +} + +async fn proc_run( + session: WezSession, + state: Arc>, + reply: F, + cmd: String, + args: Vec, +) -> io::Result +where + F: FnMut(Vec) -> ReplyRet + Clone + Send + 'static, +{ + let id = rand::random(); + let cmd_string = format!("{} {}", cmd, args.join(" ")); + + let ExecResult { + mut stdin, + mut stdout, + mut stderr, + mut child, + } = session + .exec(&cmd_string, None) + .compat() + .await + .map_err(to_other_error)?; + + // Force stdin, stdout, and stderr to be nonblocking + stdin + .set_non_blocking(true) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + stdout + .set_non_blocking(true) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + stderr + .set_non_blocking(true) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + + // Check if the process died immediately and report + // an error if that's the case + if let Ok(Some(exit_status)) = child.try_wait() { + return Err(io::Error::new( + io::ErrorKind::BrokenPipe, + format!("Process exited early: {:?}", exit_status), + )); + } + + let (stdin_tx, mut stdin_rx) = mpsc::channel(1); + let (kill_tx, mut kill_rx) = mpsc::channel(1); + state.lock().await.processes.insert( + id, + Process { + id, + cmd, + args, + stdin_tx, + kill_tx, + }, + ); + + let post_hook = Box::new(move || { + // Spawn a task that sends stdout as a response + let mut reply_2 = reply.clone(); + let stdout_task = tokio::spawn(async move { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match stdout.read(&mut buf) { + Ok(n) if n > 0 => match String::from_utf8(buf[..n].to_vec()) { + Ok(data) => { + let payload = vec![ResponseData::ProcStdout { id, data }]; + if !reply_2(payload).await { + error!(" Stdout channel closed", id); + break; + } + + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis( + READ_PAUSE_MILLIS, + )) + .await; + } + Err(x) => { + error!( + " Invalid data read from stdout pipe: {}", + id, x + ); + break; + } + }, + Ok(_) => break, + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) + .await; + } + Err(_) => break, + } + } + }); + + // Spawn a task that sends stderr as a response + let mut reply_2 = reply.clone(); + let stderr_task = tokio::spawn(async move { + let mut buf: [u8; MAX_PIPE_CHUNK_SIZE] = [0; MAX_PIPE_CHUNK_SIZE]; + loop { + match stderr.read(&mut buf) { + Ok(n) if n > 0 => match String::from_utf8(buf[..n].to_vec()) { + Ok(data) => { + let payload = vec![ResponseData::ProcStderr { id, data }]; + if !reply_2(payload).await { + error!(" Stderr channel closed", id); + break; + } + + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis( + READ_PAUSE_MILLIS, + )) + .await; + } + Err(x) => { + error!( + " Invalid data read from stderr pipe: {}", + id, x + ); + break; + } + }, + Ok(_) => break, + Err(x) if x.kind() == io::ErrorKind::WouldBlock => { + // Pause to allow buffer to fill up a little bit, avoiding + // spamming with a lot of smaller responses + tokio::time::sleep(tokio::time::Duration::from_millis(READ_PAUSE_MILLIS)) + .await; + } + Err(_) => break, + } + } + }); + + let stdin_task = tokio::spawn(async move { + while let Some(line) = stdin_rx.recv().await { + if let Err(x) = stdin.write_all(line.as_bytes()) { + error!(" Failed to send stdin: {}", id, x); + break; + } + } + }); + + // Spawn a task that waits on the process to exit but can also + // kill the process when triggered + let state_2 = Arc::clone(&state); + let mut reply_2 = reply.clone(); + tokio::spawn(async move { + let mut should_kill = false; + let mut success = false; + tokio::select! { + _ = kill_rx.recv() => { + should_kill = true; + } + result = child.async_wait().compat() => { + match result { + Ok(status) => { + success = status.success(); + } + Err(x) => { + error!(" Waiting on process failed: {}", id, x); + } + } + } + } + + // Force stdin task to abort if it hasn't exited as there is no + // point to sending any more stdin + stdin_task.abort(); + + if should_kill { + debug!(" Process killed", id); + + if let Err(x) = child.kill() { + error!(" Unable to kill process: {}", id, x); + } + + // NOTE: At the moment, child.kill does nothing for wezterm_ssh::SshChildProcess; + // so, we need to manually run kill/taskkill to make sure that the + // process is sent a kill signal + if let Some(pid) = child.process_id() { + let _ = session + .exec(&format!("kill -9 {}", pid), None) + .compat() + .await; + let _ = session + .exec(&format!("taskkill /F /PID {}", pid), None) + .compat() + .await; + } + } else { + debug!(" Process done", id); + } + + if let Err(x) = stderr_task.await { + error!(" Join on stderr task failed: {}", id, x); + } + + if let Err(x) = stdout_task.await { + error!(" Join on stdout task failed: {}", id, x); + } + + state_2.lock().await.processes.remove(&id); + + let payload = vec![ResponseData::ProcDone { + id, + success: !should_kill && success, + code: None, + }]; + + if !reply_2(payload).await { + error!(" Failed to send done!", id,); + } + }); + }); + + Ok(Outgoing { + data: ResponseData::ProcStart { id }, + post_hook: Some(post_hook), + }) +} + +async fn proc_kill( + _session: WezSession, + state: Arc>, + id: usize, +) -> io::Result { + if let Some(process) = state.lock().await.processes.remove(&id) { + if process.kill_tx.send(()).await.is_ok() { + return Ok(Outgoing::from(ResponseData::Ok)); + } + } + + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Unable to send kill signal to process", + )) +} + +async fn proc_stdin( + _session: WezSession, + state: Arc>, + id: usize, + data: String, +) -> io::Result { + if let Some(process) = state.lock().await.processes.get_mut(&id) { + if process.stdin_tx.send(data).await.is_ok() { + return Ok(Outgoing::from(ResponseData::Ok)); + } + } + + Err(io::Error::new( + io::ErrorKind::BrokenPipe, + "Unable to send stdin to process", + )) +} + +async fn proc_list(_session: WezSession, state: Arc>) -> io::Result { + Ok(Outgoing::from(ResponseData::ProcEntries { + entries: state + .lock() + .await + .processes + .values() + .map(|p| RunningProcess { + cmd: p.cmd.to_string(), + args: p.args.clone(), + id: p.id, + }) + .collect(), + })) +} + +async fn system_info(session: WezSession) -> io::Result { + let current_dir = session + .sftp() + .realpath(".") + .compat() + .await + .map_err(to_other_error)?; + + let first_component = current_dir.components().next(); + let is_windows = + first_component.is_some() && matches!(first_component.unwrap(), Component::Prefix(_)); + let is_unix = current_dir.as_os_str().to_string_lossy().starts_with('/'); + + let family = if is_windows { + "windows" + } else if is_unix { + "unix" + } else { + "" + } + .to_string(); + + Ok(Outgoing::from(ResponseData::SystemInfo { + family, + os: "".to_string(), + arch: "".to_string(), + current_dir, + main_separator: if is_windows { '\\' } else { '/' }, + })) +} diff --git a/distant-ssh2/src/lib.rs b/distant-ssh2/src/lib.rs new file mode 100644 index 0000000..8eef084 --- /dev/null +++ b/distant-ssh2/src/lib.rs @@ -0,0 +1,255 @@ +use async_compat::CompatExt; +use distant_core::{Request, Session, Transport}; +use log::*; +use smol::channel::Receiver as SmolReceiver; +use std::{ + io::{self, Write}, + path::PathBuf, + sync::Arc, +}; +use tokio::sync::{mpsc, Mutex}; +use wezterm_ssh::{Config as WezConfig, Session as WezSession, SessionEvent as WezSessionEvent}; + +mod handler; + +#[derive(Debug)] +pub struct Ssh2AuthPrompt { + /// The label to show when prompting the user + pub prompt: String, + + /// If true, the response that the user inputs should be displayed as they type. If false then + /// treat it as a password entry and do not display what is typed in response to this prompt. + pub echo: bool, +} + +#[derive(Debug)] +pub struct Ssh2AuthEvent { + /// Represents the name of the user to be authenticated. This may be empty! + pub username: String, + + /// Informational text to be displayed to the user prior to the prompt + pub instructions: String, + + /// Prompts to be conveyed to the user, each representing a single answer needed + pub prompts: Vec, +} + +#[derive(Clone, Debug, Default)] +pub struct Ssh2SessionOpts { + pub identity_files: Vec, + pub identities_only: Option, + pub port: Option, + pub proxy_command: Option, + pub user: Option, + pub user_known_hosts_files: Vec, +} + +pub struct Ssh2AuthHandler { + pub on_authenticate: Box io::Result>>, + pub on_banner: Box, + pub on_host_verify: Box io::Result>, + pub on_error: Box, +} + +impl Default for Ssh2AuthHandler { + fn default() -> Self { + Self { + on_authenticate: Box::new(|ev| { + if !ev.username.is_empty() { + eprintln!("Authentication for {}", ev.username); + } + + if !ev.instructions.is_empty() { + eprintln!("{}", ev.instructions); + } + + let mut answers = Vec::new(); + for prompt in &ev.prompts { + // Contains all prompt lines including same line + let mut prompt_lines = prompt.prompt.split('\n').collect::>(); + + // Line that is prompt on same line as answer + let prompt_line = prompt_lines.pop().unwrap(); + + // Go ahead and display all other lines + for line in prompt_lines.into_iter() { + eprintln!("{}", line); + } + + let answer = if prompt.echo { + eprint!("{}", prompt_line); + std::io::stderr().lock().flush()?; + + let mut answer = String::new(); + std::io::stdin().read_line(&mut answer)?; + answer + } else { + rpassword::prompt_password_stderr(prompt_line)? + }; + + answers.push(answer); + } + Ok(answers) + }), + on_banner: Box::new(|_| {}), + on_host_verify: Box::new(|message| { + eprintln!("{}", message); + match rpassword::prompt_password_stderr("Enter [y/N]> ")?.as_str() { + "y" | "Y" | "yes" | "YES" => Ok(true), + _ => Ok(false), + } + }), + on_error: Box::new(|_| {}), + } + } +} + +pub struct Ssh2Session { + session: WezSession, + events: SmolReceiver, +} + +impl Ssh2Session { + /// Connect to a remote TCP server using SSH + pub fn connect(host: impl AsRef, opts: Ssh2SessionOpts) -> io::Result { + let mut config = WezConfig::new(); + config.add_default_config_files(); + + // Grab the config for the specific host + let mut config = config.for_host(host.as_ref()); + + // Override config with any settings provided by session opts + if let Some(port) = opts.port.as_ref() { + config.insert("port".to_string(), port.to_string()); + } + if let Some(user) = opts.user.as_ref() { + config.insert("user".to_string(), user.to_string()); + } + if !opts.identity_files.is_empty() { + config.insert( + "identityfile".to_string(), + opts.identity_files + .iter() + .filter_map(|p| p.to_str()) + .map(ToString::to_string) + .collect::>() + .join(" "), + ); + } + if let Some(yes) = opts.identities_only.as_ref() { + let value = if *yes { + "yes".to_string() + } else { + "no".to_string() + }; + config.insert("identitiesonly".to_string(), value); + } + if let Some(cmd) = opts.proxy_command.as_ref() { + config.insert("proxycommand".to_string(), cmd.to_string()); + } + if !opts.user_known_hosts_files.is_empty() { + config.insert( + "userknownhostsfile".to_string(), + opts.user_known_hosts_files + .iter() + .filter_map(|p| p.to_str()) + .map(ToString::to_string) + .collect::>() + .join(" "), + ); + } + + // Establish a connection + let (session, events) = + WezSession::connect(config).map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + + Ok(Self { session, events }) + } + + /// Authenticates the [`Ssh2Session`] and produces a [`Session`] + pub async fn authenticate(self, mut handler: Ssh2AuthHandler) -> io::Result { + // Perform the authentication by listening for events and continuing to handle them + // until authenticated + while let Ok(event) = self.events.recv().await { + match event { + WezSessionEvent::Banner(banner) => { + if let Some(banner) = banner { + (handler.on_banner)(banner.as_ref()); + } + } + WezSessionEvent::HostVerify(verify) => { + let verified = (handler.on_host_verify)(verify.message.as_str())?; + verify + .answer(verified) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + } + WezSessionEvent::Authenticate(mut auth) => { + let ev = Ssh2AuthEvent { + username: auth.username.clone(), + instructions: auth.instructions.clone(), + prompts: auth + .prompts + .drain(..) + .map(|p| Ssh2AuthPrompt { + prompt: p.prompt, + echo: p.echo, + }) + .collect(), + }; + + let answers = (handler.on_authenticate)(ev)?; + auth.answer(answers) + .compat() + .await + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + } + WezSessionEvent::Error(err) => { + (handler.on_error)(&err); + return Err(io::Error::new(io::ErrorKind::PermissionDenied, err)); + } + WezSessionEvent::Authenticated => break, + } + } + + // We are now authenticated, so convert into a distant session that wraps our ssh2 session + self.into_session() + } + + /// Consume [`Ssh2Session`] and produce a distant [`Session`] + fn into_session(self) -> io::Result { + let (t1, t2) = Transport::pair(1); + let session = Session::initialize(t1)?; + + // Spawn tasks that forward requests to the ssh session + // and send back responses from the ssh session + let (mut t_read, mut t_write) = t2.into_split(); + let Self { + session: wez_session, + .. + } = self; + + let (tx, mut rx) = mpsc::channel(1); + tokio::spawn(async move { + let state = Arc::new(Mutex::new(handler::State::default())); + while let Ok(Some(req)) = t_read.receive::().await { + if let Err(x) = + handler::process(wez_session.clone(), Arc::clone(&state), req, tx.clone()).await + { + error!("{}", x); + } + } + }); + + tokio::spawn(async move { + while let Some(res) = rx.recv().await { + if t_write.send(res).await.is_err() { + break; + } + } + }); + + Ok(session) + } +} diff --git a/distant-ssh2/tests/lib.rs b/distant-ssh2/tests/lib.rs new file mode 100644 index 0000000..0456b85 --- /dev/null +++ b/distant-ssh2/tests/lib.rs @@ -0,0 +1,2 @@ +mod ssh2; +mod sshd; diff --git a/distant-ssh2/tests/ssh2/mod.rs b/distant-ssh2/tests/ssh2/mod.rs new file mode 100644 index 0000000..7f33ec2 --- /dev/null +++ b/distant-ssh2/tests/ssh2/mod.rs @@ -0,0 +1 @@ +mod session; diff --git a/distant-ssh2/tests/ssh2/session.rs b/distant-ssh2/tests/ssh2/session.rs new file mode 100644 index 0000000..9444163 --- /dev/null +++ b/distant-ssh2/tests/ssh2/session.rs @@ -0,0 +1,1871 @@ +use crate::sshd::*; +use assert_fs::{prelude::*, TempDir}; +use distant_core::{ + FileType, Request, RequestData, Response, ResponseData, RunningProcess, Session, +}; +use once_cell::sync::Lazy; +use predicates::prelude::*; +use rstest::*; +use std::{ + env, + path::{Path, PathBuf}, + time::Duration, +}; + +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| TempDir::new().unwrap()); +static SCRIPT_RUNNER: Lazy = Lazy::new(|| String::from("bash")); + +static ECHO_ARGS_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" + "# + )) + .unwrap(); + script +}); + +static ECHO_ARGS_TO_STDERR_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + printf "%s" "$*" 1>&2 + "# + )) + .unwrap(); + script +}); + +static ECHO_STDIN_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" + #/usr/bin/env bash + while IFS= read; do echo "$REPLY"; done + "# + )) + .unwrap(); + script +}); + +static SLEEP_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("sleep.sh"); + script + .write_str(indoc::indoc!( + r#" + #!/usr/bin/env bash + sleep "$1" + "# + )) + .unwrap(); + script +}); + +static DOES_NOT_EXIST_BIN: Lazy = + Lazy::new(|| TEMP_SCRIPT_DIR.child("does_not_exist_bin")); + +#[rstest] +#[tokio::test] +async fn file_read_should_send_error_if_fails_to_read_file(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let path = temp.child("missing-file").path().to_path_buf(); + let req = Request::new("test-tenant", vec![RequestData::FileRead { path }]); + let res = session.send(req).await.unwrap(); + + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn file_read_should_send_blob_with_file_contents(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileRead { + path: file.path().to_path_buf(), + }], + ); + let res = session.send(req).await.unwrap(); + + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::Blob { data } => assert_eq!(data, b"some file contents"), + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn file_read_text_should_send_error_if_fails_to_read_file(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let path = temp.child("missing-file").path().to_path_buf(); + let req = Request::new("test-tenant", vec![RequestData::FileReadText { path }]); + let res = session.send(req).await.unwrap(); + + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn file_read_text_should_send_text_with_file_contents(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileReadText { + path: file.path().to_path_buf(), + }], + ); + let res = session.send(req).await.unwrap(); + + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::Text { data } => assert_eq!(data, "some file contents"), + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn file_write_should_send_error_if_fails_to_write_file(#[future] session: Session) { + let mut session = session.await; + // Create a temporary path and add to it to ensure that there are + // extra components that don't exist to cause writing to fail + let temp = TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileWrite { + path: file.path().to_path_buf(), + data: b"some text".to_vec(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn file_write_should_send_ok_when_successful(#[future] session: Session) { + let mut session = session.await; + // Path should point to a file that does not exist, but all + // other components leading up to it do + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileWrite { + path: file.path().to_path_buf(), + data: b"some text".to_vec(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we actually did create the file + // with the associated contents + file.assert("some text"); +} + +#[rstest] +#[tokio::test] +async fn file_write_text_should_send_error_if_fails_to_write_file(#[future] session: Session) { + let mut session = session.await; + // Create a temporary path and add to it to ensure that there are + // extra components that don't exist to cause writing to fail + let temp = TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileWriteText { + path: file.path().to_path_buf(), + text: String::from("some text"), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn file_write_text_should_send_ok_when_successful(#[future] session: Session) { + let mut session = session.await; + // Path should point to a file that does not exist, but all + // other components leading up to it do + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileWriteText { + path: file.path().to_path_buf(), + text: String::from("some text"), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we actually did create the file + // with the associated contents + file.assert("some text"); +} + +#[rstest] +#[tokio::test] +async fn file_append_should_send_error_if_fails_to_create_file(#[future] session: Session) { + let mut session = session.await; + // Create a temporary path and add to it to ensure that there are + // extra components that don't exist to cause writing to fail + let temp = TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileAppend { + path: file.path().to_path_buf(), + data: b"some extra contents".to_vec(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn file_append_should_send_ok_when_successful(#[future] session: Session) { + let mut session = session.await; + // Create a temporary file and fill it with some contents + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileAppend { + path: file.path().to_path_buf(), + data: b"some extra contents".to_vec(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Yield to allow chance to finish appending to file + tokio::time::sleep(Duration::from_millis(50)).await; + + // Also verify that we actually did append to the file + file.assert("some file contentssome extra contents"); +} + +#[rstest] +#[tokio::test] +async fn file_append_text_should_send_error_if_fails_to_create_file(#[future] session: Session) { + let mut session = session.await; + // Create a temporary path and add to it to ensure that there are + // extra components that don't exist to cause writing to fail + let temp = TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileAppendText { + path: file.path().to_path_buf(), + text: String::from("some extra contents"), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn file_append_text_should_send_ok_when_successful(#[future] session: Session) { + let mut session = session.await; + // Create a temporary file and fill it with some contents + let temp = TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::FileAppendText { + path: file.path().to_path_buf(), + text: String::from("some extra contents"), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Yield to allow chance to finish appending to file + tokio::time::sleep(Duration::from_millis(50)).await; + + // Also verify that we actually did append to the file + file.assert("some file contentssome extra contents"); +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_send_error_if_directory_does_not_exist(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let dir = temp.child("test-dir"); + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: dir.path().to_path_buf(), + depth: 0, + absolute: false, + canonicalize: false, + include_root: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +// /root/ +// /root/file1 +// /root/link1 -> /root/sub1/file2 +// /root/sub1/ +// /root/sub1/file2 +async fn setup_dir() -> TempDir { + let root_dir = TempDir::new().unwrap(); + root_dir.child("file1").touch().unwrap(); + + let sub1 = root_dir.child("sub1"); + sub1.create_dir_all().unwrap(); + + let file2 = sub1.child("file2"); + file2.touch().unwrap(); + + let link1 = root_dir.child("link1"); + link1.symlink_to_file(file2.path()).unwrap(); + + root_dir +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_support_depth_limits(#[future] session: Session) { + let mut session = session.await; + // Create directory with some nested items + let root_dir = setup_dir().await; + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: root_dir.path().to_path_buf(), + depth: 1, + absolute: false, + canonicalize: false, + include_root: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::DirEntries { entries, .. } => { + assert_eq!(entries.len(), 3, "Wrong number of entries found"); + + assert_eq!(entries[0].file_type, FileType::File); + assert_eq!(entries[0].path, Path::new("file1")); + assert_eq!(entries[0].depth, 1); + + assert_eq!(entries[1].file_type, FileType::Symlink); + assert_eq!(entries[1].path, Path::new("link1")); + assert_eq!(entries[1].depth, 1); + + assert_eq!(entries[2].file_type, FileType::Dir); + assert_eq!(entries[2].path, Path::new("sub1")); + assert_eq!(entries[2].depth, 1); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_support_unlimited_depth_using_zero(#[future] session: Session) { + let mut session = session.await; + // Create directory with some nested items + let root_dir = setup_dir().await; + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: root_dir.path().to_path_buf(), + depth: 0, + absolute: false, + canonicalize: false, + include_root: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::DirEntries { entries, .. } => { + assert_eq!(entries.len(), 4, "Wrong number of entries found"); + + assert_eq!(entries[0].file_type, FileType::File); + assert_eq!(entries[0].path, Path::new("file1")); + assert_eq!(entries[0].depth, 1); + + assert_eq!(entries[1].file_type, FileType::Symlink); + assert_eq!(entries[1].path, Path::new("link1")); + assert_eq!(entries[1].depth, 1); + + assert_eq!(entries[2].file_type, FileType::Dir); + assert_eq!(entries[2].path, Path::new("sub1")); + assert_eq!(entries[2].depth, 1); + + assert_eq!(entries[3].file_type, FileType::File); + assert_eq!(entries[3].path, Path::new("sub1").join("file2")); + assert_eq!(entries[3].depth, 2); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_support_including_directory_in_returned_entries( + #[future] session: Session, +) { + let mut session = session.await; + // Create directory with some nested items + let root_dir = setup_dir().await; + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: root_dir.path().to_path_buf(), + depth: 1, + absolute: false, + canonicalize: false, + include_root: true, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::DirEntries { entries, .. } => { + assert_eq!(entries.len(), 4, "Wrong number of entries found"); + + // NOTE: Root entry is always absolute, resolved path + assert_eq!(entries[0].file_type, FileType::Dir); + assert_eq!(entries[0].path, root_dir.path().canonicalize().unwrap()); + assert_eq!(entries[0].depth, 0); + + assert_eq!(entries[1].file_type, FileType::File); + assert_eq!(entries[1].path, Path::new("file1")); + assert_eq!(entries[1].depth, 1); + + assert_eq!(entries[2].file_type, FileType::Symlink); + assert_eq!(entries[2].path, Path::new("link1")); + assert_eq!(entries[2].depth, 1); + + assert_eq!(entries[3].file_type, FileType::Dir); + assert_eq!(entries[3].path, Path::new("sub1")); + assert_eq!(entries[3].depth, 1); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_support_returning_absolute_paths(#[future] session: Session) { + let mut session = session.await; + // Create directory with some nested items + let root_dir = setup_dir().await; + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: root_dir.path().to_path_buf(), + depth: 1, + absolute: true, + canonicalize: false, + include_root: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::DirEntries { entries, .. } => { + assert_eq!(entries.len(), 3, "Wrong number of entries found"); + let root_path = root_dir.path().canonicalize().unwrap(); + + assert_eq!(entries[0].file_type, FileType::File); + assert_eq!(entries[0].path, root_path.join("file1")); + assert_eq!(entries[0].depth, 1); + + assert_eq!(entries[1].file_type, FileType::Symlink); + assert_eq!(entries[1].path, root_path.join("link1")); + assert_eq!(entries[1].depth, 1); + + assert_eq!(entries[2].file_type, FileType::Dir); + assert_eq!(entries[2].path, root_path.join("sub1")); + assert_eq!(entries[2].depth, 1); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn dir_read_should_support_returning_canonicalized_paths(#[future] session: Session) { + let mut session = session.await; + // Create directory with some nested items + let root_dir = setup_dir().await; + + let req = Request::new( + "test-tenant", + vec![RequestData::DirRead { + path: root_dir.path().to_path_buf(), + depth: 1, + absolute: false, + canonicalize: true, + include_root: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::DirEntries { entries, .. } => { + assert_eq!(entries.len(), 3, "Wrong number of entries found"); + + assert_eq!(entries[0].file_type, FileType::File); + assert_eq!(entries[0].path, Path::new("file1")); + assert_eq!(entries[0].depth, 1); + + assert_eq!(entries[1].file_type, FileType::Dir); + assert_eq!(entries[1].path, Path::new("sub1")); + assert_eq!(entries[1].depth, 1); + + // Symlink should be resolved from $ROOT/link1 -> $ROOT/sub1/file2 + assert_eq!(entries[2].file_type, FileType::Symlink); + assert_eq!(entries[2].path, Path::new("sub1").join("file2")); + assert_eq!(entries[2].depth, 1); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn dir_create_should_send_error_if_fails(#[future] session: Session) { + let mut session = session.await; + // Make a path that has multiple non-existent components + // so the creation will fail + let root_dir = setup_dir().await; + let path = root_dir.path().join("nested").join("new-dir"); + + let req = Request::new( + "test-tenant", + vec![RequestData::DirCreate { + path: path.to_path_buf(), + all: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that the directory was not actually created + assert!(!path.exists(), "Path unexpectedly exists"); +} + +#[rstest] +#[tokio::test] +async fn dir_create_should_send_ok_when_successful(#[future] session: Session) { + let mut session = session.await; + let root_dir = setup_dir().await; + let path = root_dir.path().join("new-dir"); + + let req = Request::new( + "test-tenant", + vec![RequestData::DirCreate { + path: path.to_path_buf(), + all: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} + +#[rstest] +#[tokio::test] +async fn dir_create_should_support_creating_multiple_dir_components(#[future] session: Session) { + let mut session = session.await; + let root_dir = setup_dir().await; + let path = root_dir.path().join("nested").join("new").join("dir"); + + let req = Request::new( + "test-tenant", + vec![RequestData::DirCreate { + path: path.to_path_buf(), + all: true, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} + +#[rstest] +#[tokio::test] +async fn remove_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("missing-file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Remove { + path: file.path().to_path_buf(), + force: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn remove_should_support_deleting_a_directory(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Remove { + path: dir.path().to_path_buf(), + force: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn remove_should_delete_nonempty_directory_if_force_is_true(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + dir.child("file").touch().unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Remove { + path: dir.path().to_path_buf(), + force: true, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn remove_should_support_deleting_a_single_file(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("some-file"); + file.touch().unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Remove { + path: file.path().to_path_buf(), + force: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn copy_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Copy { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn copy_should_support_copying_an_entire_directory(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let src_file = src.child("file"); + src_file.write_str("some contents").unwrap(); + + let dst = temp.child("dst"); + let dst_file = dst.child("file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Copy { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we have source and destination directories and associated contents + src.assert(predicate::path::is_dir()); + src_file.assert(predicate::path::is_file()); + dst.assert(predicate::path::is_dir()); + dst_file.assert(predicate::path::eq_file(src_file.path())); +} + +#[rstest] +#[tokio::test] +async fn copy_should_support_copying_an_empty_directory(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let dst = temp.child("dst"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Copy { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we still have source and destination directories + src.assert(predicate::path::is_dir()); + dst.assert(predicate::path::is_dir()); +} + +#[rstest] +#[tokio::test] +async fn copy_should_support_copying_a_directory_that_only_contains_directories( + #[future] session: Session, +) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let src_dir = src.child("dir"); + src_dir.create_dir_all().unwrap(); + + let dst = temp.child("dst"); + let dst_dir = dst.child("dir"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Copy { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we have source and destination directories and associated contents + src.assert(predicate::path::is_dir().name("src")); + src_dir.assert(predicate::path::is_dir().name("src/dir")); + dst.assert(predicate::path::is_dir().name("dst")); + dst_dir.assert(predicate::path::is_dir().name("dst/dir")); +} + +#[rstest] +#[tokio::test] +async fn copy_should_support_copying_a_single_file(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Copy { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we still have source and that destination has source's contents + src.assert(predicate::path::is_file()); + dst.assert(predicate::path::eq_file(src.path())); +} + +#[rstest] +#[tokio::test] +async fn rename_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Rename { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +#[tokio::test] +async fn rename_should_support_renaming_an_entire_directory(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let src_file = src.child("file"); + src_file.write_str("some contents").unwrap(); + + let dst = temp.child("dst"); + let dst_file = dst.child("file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Rename { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we moved the contents + src.assert(predicate::path::missing()); + src_file.assert(predicate::path::missing()); + dst.assert(predicate::path::is_dir()); + dst_file.assert("some contents"); +} + +#[rstest] +#[tokio::test] +async fn rename_should_support_renaming_a_single_file(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Rename { + src: src.path().to_path_buf(), + dst: dst.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Ok), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Verify that we moved the file + src.assert(predicate::path::missing()); + dst.assert("some text"); +} + +#[rstest] +#[tokio::test] +async fn exists_should_send_true_if_path_exists(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + file.touch().unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Exists { + path: file.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert_eq!(res.payload[0], ResponseData::Exists(true)); +} + +#[rstest] +#[tokio::test] +async fn exists_should_send_false_if_path_does_not_exist(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Exists { + path: file.path().to_path_buf(), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert_eq!(res.payload[0], ResponseData::Exists(false)); +} + +#[rstest] +#[tokio::test] +async fn metadata_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: file.path().to_path_buf(), + canonicalize: false, + resolve_file_type: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn metadata_should_send_back_metadata_on_file_if_exists(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: file.path().to_path_buf(), + canonicalize: false, + resolve_file_type: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!( + res.payload[0], + ResponseData::Metadata { + canonicalized_path: None, + file_type: FileType::File, + len: 9, + readonly: false, + .. + } + ), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn metadata_should_send_back_metadata_on_dir_if_exists(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: dir.path().to_path_buf(), + canonicalize: false, + resolve_file_type: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!( + res.payload[0], + ResponseData::Metadata { + canonicalized_path: None, + file_type: FileType::Dir, + readonly: false, + .. + } + ), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn metadata_should_send_back_metadata_on_symlink_if_exists(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: symlink.path().to_path_buf(), + canonicalize: false, + resolve_file_type: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!( + res.payload[0], + ResponseData::Metadata { + canonicalized_path: None, + file_type: FileType::Symlink, + readonly: false, + .. + } + ), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn metadata_should_include_canonicalized_path_if_flag_specified(#[future] session: Session) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: symlink.path().to_path_buf(), + canonicalize: true, + resolve_file_type: false, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::Metadata { + canonicalized_path: Some(path), + file_type: FileType::Symlink, + readonly: false, + .. + } => assert_eq!( + path, + &file.path().canonicalize().unwrap(), + "Symlink canonicalized path does not match referenced file" + ), + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn metadata_should_resolve_file_type_of_symlink_if_flag_specified( + #[future] session: Session, +) { + let mut session = session.await; + let temp = TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + + let req = Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: symlink.path().to_path_buf(), + canonicalize: false, + resolve_file_type: true, + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::Metadata { + file_type: FileType::File, + .. + } => {} + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_run_should_send_error_over_stderr_on_failure(#[future] session: Session) { + let mut session = session.await; + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(), + args: Vec::new(), + }], + ); + + // NOTE: This diverges from distant in that we don't get an error message and instead + // will always get stderr as ssh runs every command in some kind of shell + let mut mailbox = session.mail(req).await.unwrap(); + + // Get proc start message + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + let proc_id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + // Get proc stderr message + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcStderr { id, .. } => { + assert_eq!(proc_id, *id, "Wrong process stderr received"); + } + x => panic!("Unexpected response: {:?}", x), + } + + // Get proc done message + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcDone { id, .. } => { + assert_eq!(proc_id, *id, "Wrong process done received"); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_run_should_send_back_proc_start_on_success(#[future] session: Session) { + let mut session = session.await; + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()], + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(&res.payload[0], ResponseData::ProcStart { .. }), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[tokio::test] +#[cfg_attr(windows, ignore)] +async fn proc_run_should_send_back_stdout_periodically_when_available(#[future] session: Session) { + let mut session = session.await; + // Run a program that echoes to stdout + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![ + ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string(), + String::from("'some stdout'"), + ], + }], + ); + + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(&res.payload[0], ResponseData::ProcStart { .. }), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Gather two additional responses: + // + // 1. An indirect response for stdout + // 2. An indirect response that is proc completing + // + // Note that order is not a guarantee, so we have to check that + // we get one of each type of response + let res1 = mailbox.next().await.expect("Missing first response"); + let res2 = mailbox.next().await.expect("Missing second response"); + + let mut got_stdout = false; + let mut got_done = false; + + let mut check_res = |res: &Response| { + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcStdout { data, .. } => { + assert_eq!(data, "some stdout", "Got wrong stdout"); + got_stdout = true; + } + ResponseData::ProcDone { success, .. } => { + assert!(success, "Process should have completed successfully"); + got_done = true; + } + x => panic!("Unexpected response: {:?}", x), + } + }; + + check_res(&res1); + check_res(&res2); + assert!(got_stdout, "Missing stdout response"); + assert!(got_done, "Missing done response"); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[tokio::test] +#[cfg_attr(windows, ignore)] +async fn proc_run_should_send_back_stderr_periodically_when_available(#[future] session: Session) { + let mut session = session.await; + // Run a program that echoes to stderr + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![ + ECHO_ARGS_TO_STDERR_SH.to_str().unwrap().to_string(), + String::from("'some stderr'"), + ], + }], + ); + + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert!( + matches!(&res.payload[0], ResponseData::ProcStart { .. }), + "Unexpected response: {:?}", + res.payload[0] + ); + + // Gather two additional responses: + // + // 1. An indirect response for stderr + // 2. An indirect response that is proc completing + // + // Note that order is not a guarantee, so we have to check that + // we get one of each type of response + let res1 = mailbox.next().await.expect("Missing first response"); + let res2 = mailbox.next().await.expect("Missing second response"); + + let mut got_stderr = false; + let mut got_done = false; + + let mut check_res = |res: &Response| { + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcStderr { data, .. } => { + assert_eq!(data, "some stderr", "Got wrong stderr"); + got_stderr = true; + } + ResponseData::ProcDone { success, .. } => { + assert!(success, "Process should have completed successfully"); + got_done = true; + } + x => panic!("Unexpected response: {:?}", x), + } + }; + + check_res(&res1); + check_res(&res2); + assert!(got_stderr, "Missing stderr response"); + assert!(got_done, "Missing done response"); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[tokio::test] +#[cfg_attr(windows, ignore)] +async fn proc_run_should_clear_process_from_state_when_done(#[future] session: Session) { + let mut session = session.await; + // Run a program that ends after a little bit + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0.1")], + }], + ); + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + let id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + // Verify that the state has the process + let res = session + .send(Request::new("test-tenant", vec![RequestData::ProcList {}])) + .await + .unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcEntries { entries } => assert_eq!(entries[0].id, id), + x => panic!("Unexpected response: {:?}", x), + } + + // Wait for process to finish + let _ = mailbox.next().await.unwrap(); + + // Verify that the state was cleared + let res = session + .send(Request::new("test-tenant", vec![RequestData::ProcList {}])) + .await + .unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcEntries { entries } => assert!(entries.is_empty(), "Proc not cleared"), + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_run_should_clear_process_from_state_when_killed(#[future] session: Session) { + let mut session = session.await; + // Run a program that ends slowly + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")], + }], + ); + + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + let id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + // Verify that the state has the process + let res = session + .send(Request::new("test-tenant", vec![RequestData::ProcList {}])) + .await + .unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcEntries { entries } => assert_eq!(entries[0].id, id), + x => panic!("Unexpected response: {:?}", x), + } + + // Send kill signal + let req = Request::new("test-tenant", vec![RequestData::ProcKill { id }]); + let _ = session.send(req).await.unwrap(); + + // Wait for the proc done + let _ = mailbox.next().await.unwrap(); + + // Verify that the state was cleared + let res = session + .send(Request::new("test-tenant", vec![RequestData::ProcList {}])) + .await + .unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + match &res.payload[0] { + ResponseData::ProcEntries { entries } => assert!(entries.is_empty(), "Proc not cleared"), + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_kill_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + // Send kill to a non-existent process + let req = Request::new( + "test-tenant", + vec![RequestData::ProcKill { id: 0xDEADBEEF }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Verify that we get an error + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn proc_kill_should_send_ok_and_done_responses_on_success(#[future] session: Session) { + let mut session = session.await; + // First, run a program that sits around (sleep for 1 second) + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")], + }], + ); + + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Second, grab the id of the started process + let id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + // Third, send kill for process + // NOTE: We cannot let the state get dropped as it results in killing + // the child process automatically; so, we clone another reference here + let req = Request::new("test-tenant", vec![RequestData::ProcKill { id }]); + let res = session.send(req).await.unwrap(); + match &res.payload[0] { + ResponseData::Ok => {} + x => panic!("Unexpected response: {:?}", x), + } + + // Fourth, verify that the process completes + let res = mailbox.next().await.unwrap(); + match &res.payload[0] { + ResponseData::ProcDone { success, .. } => { + assert!(!success, "Process should not have completed successfully"); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_stdin_should_send_error_on_failure(#[future] session: Session) { + let mut session = session.await; + // Send stdin to a non-existent process + let req = Request::new( + "test-tenant", + vec![RequestData::ProcStdin { + id: 0xDEADBEEF, + data: String::from("some input"), + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Verify that we get an error + assert!( + matches!(res.payload[0], ResponseData::Error(_)), + "Unexpected response: {:?}", + res.payload[0] + ); +} + +// NOTE: Ignoring on windows because it's using WSL which wants a Linux path +// with / but thinks it's on windows and is providing \ +#[rstest] +#[tokio::test] +#[cfg_attr(windows, ignore)] +async fn proc_stdin_should_send_ok_on_success_and_properly_send_stdin_to_process( + #[future] session: Session, +) { + let mut session = session.await; + + // First, run a program that listens for stdin + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()], + }], + ); + let mut mailbox = session.mail(req).await.unwrap(); + + let res = mailbox.next().await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Second, grab the id of the started process + let id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + // Third, send stdin to the remote process + // NOTE: We cannot let the state get dropped as it results in killing + // the child process; so, we clone another reference here + let req = Request::new( + "test-tenant", + vec![RequestData::ProcStdin { + id, + data: String::from("hello world\n"), + }], + ); + let res = session.send(req).await.unwrap(); + match &res.payload[0] { + ResponseData::Ok => {} + x => panic!("Unexpected response: {:?}", x), + } + + // Fourth, gather an indirect response that is stdout from echoing our stdin + let res = mailbox.next().await.unwrap(); + match &res.payload[0] { + ResponseData::ProcStdout { data, .. } => { + assert_eq!(data, "hello world\n", "Mirrored data didn't match"); + } + x => panic!("Unexpected response: {:?}", x), + } +} + +#[rstest] +#[tokio::test] +async fn proc_list_should_send_proc_entry_list(#[future] session: Session) { + let mut session = session.await; + let req = Request::new( + "test-tenant", + vec![RequestData::ProcRun { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("10")], + }], + ); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Grab the id of the started process + let id = match &res.payload[0] { + ResponseData::ProcStart { id } => *id, + x => panic!("Unexpected response: {:?}", x), + }; + + let req = Request::new("test-tenant", vec![RequestData::ProcList {}]); + + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + + // Verify our process shows up in our entry list + assert_eq!( + res.payload[0], + ResponseData::ProcEntries { + entries: vec![RunningProcess { + cmd: SCRIPT_RUNNER.to_string(), + args: vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("10")], + id, + }], + }, + "Unexpected response: {:?}", + res.payload[0] + ); +} + +#[rstest] +#[tokio::test] +async fn system_info_should_send_system_info_based_on_binary(#[future] session: Session) { + let mut session = session.await; + + // Figure out what SFTP's realpath(.) would resolve to + let res = session + .send(Request::new( + "test-tenant", + vec![RequestData::Metadata { + path: PathBuf::from("."), + canonicalize: true, + resolve_file_type: false, + }], + )) + .await + .unwrap(); + let current_dir = if let ResponseData::Metadata { + canonicalized_path, .. + } = &res.payload[0] + { + canonicalized_path + .as_deref() + .expect("Missing canonicalized path") + .to_path_buf() + } else { + panic!("Failed to get metadata for '.'") + }; + + let req = Request::new("test-tenant", vec![RequestData::SystemInfo {}]); + let res = session.send(req).await.unwrap(); + assert_eq!(res.payload.len(), 1, "Wrong payload size"); + assert_eq!( + res.payload[0], + ResponseData::SystemInfo { + family: env::consts::FAMILY.to_string(), + os: "".to_string(), + arch: "".to_string(), + current_dir, + main_separator: std::path::MAIN_SEPARATOR, + }, + "Unexpected response: {:?}", + res.payload[0] + ); +} diff --git a/distant-ssh2/tests/sshd.rs b/distant-ssh2/tests/sshd.rs new file mode 100644 index 0000000..7c7a121 --- /dev/null +++ b/distant-ssh2/tests/sshd.rs @@ -0,0 +1,432 @@ +use assert_fs::{prelude::*, TempDir}; +use distant_core::Session; +use distant_ssh2::{Ssh2AuthHandler, Ssh2Session, Ssh2SessionOpts}; +use once_cell::sync::{Lazy, OnceCell}; +use rstest::*; +use std::{ + collections::HashMap, + fmt, io, + path::Path, + process::{Child, Command}, + sync::atomic::{AtomicU16, Ordering}, + thread, + time::Duration, +}; + +#[cfg(unix)] +use std::os::unix::fs::PermissionsExt; + +/// NOTE: OpenSSH's sshd requires absolute path +const BIN_PATH_STR: &str = "/usr/sbin/sshd"; + +/// Port range to use when finding a port to bind to (using IANA guidance) +const PORT_RANGE: (u16, u16) = (49152, 65535); + +static USERNAME: Lazy = Lazy::new(whoami::username); + +pub struct SshKeygen; + +impl SshKeygen { + // ssh-keygen -t rsa -f $ROOT/id_rsa -N "" -q + pub fn generate_rsa(path: impl AsRef, passphrase: impl AsRef) -> io::Result { + let res = Command::new("ssh-keygen") + .args(&["-m", "PEM"]) + .args(&["-t", "rsa"]) + .arg("-f") + .arg(path.as_ref()) + .arg("-N") + .arg(passphrase.as_ref()) + .arg("-q") + .status() + .map(|status| status.success())?; + + #[cfg(unix)] + if res { + // chmod 600 id_rsa* -> ida_rsa + ida_rsa.pub + std::fs::metadata(path.as_ref().with_extension("pub"))? + .permissions() + .set_mode(0o600); + std::fs::metadata(path)?.permissions().set_mode(0o600); + } + + Ok(res) + } +} + +pub struct SshAgent; + +impl SshAgent { + pub fn generate_shell_env() -> io::Result> { + let output = Command::new("ssh-agent").arg("-s").output()?; + let stdout = String::from_utf8(output.stdout) + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; + Ok(stdout + .split(';') + .map(str::trim) + .filter(|s| s.contains('=')) + .map(|s| { + let mut tokens = s.split('='); + let key = tokens.next().unwrap().trim().to_string(); + let rest = tokens + .map(str::trim) + .map(ToString::to_string) + .collect::>() + .join("="); + (key, rest) + }) + .collect::>()) + } + + pub fn update_tests_with_shell_env() -> io::Result<()> { + let env_map = Self::generate_shell_env()?; + for (key, value) in env_map { + std::env::set_var(key, value); + } + + Ok(()) + } +} + +#[derive(Debug)] +pub struct SshdConfig(HashMap>); + +impl Default for SshdConfig { + fn default() -> Self { + let mut config = Self::new(); + + config.set_authentication_methods(vec!["publickey".to_string()]); + config.set_use_privilege_separation(false); + config.set_subsystem(true, true); + config.set_use_pam(false); + config.set_x11_forwarding(true); + config.set_print_motd(true); + config.set_permit_tunnel(true); + config.set_kbd_interactive_authentication(true); + config.set_allow_tcp_forwarding(true); + config.set_max_startups(500, None); + config.set_strict_modes(false); + + config + } +} + +impl SshdConfig { + pub fn new() -> Self { + Self(HashMap::new()) + } + + pub fn set_authentication_methods(&mut self, methods: Vec) { + self.0.insert("AuthenticationMethods".to_string(), methods); + } + + pub fn set_authorized_keys_file(&mut self, path: impl AsRef) { + self.0.insert( + "AuthorizedKeysFile".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_host_key(&mut self, path: impl AsRef) { + self.0.insert( + "HostKey".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_pid_file(&mut self, path: impl AsRef) { + self.0.insert( + "PidFile".to_string(), + vec![path.as_ref().to_string_lossy().to_string()], + ); + } + + pub fn set_subsystem(&mut self, sftp: bool, internal_sftp: bool) { + let mut values = Vec::new(); + if sftp { + values.push("sftp".to_string()); + } + if internal_sftp { + values.push("internal-sftp".to_string()); + } + + self.0.insert("Subsystem".to_string(), values); + } + + pub fn set_use_pam(&mut self, yes: bool) { + self.0.insert("UsePAM".to_string(), Self::yes_value(yes)); + } + + pub fn set_x11_forwarding(&mut self, yes: bool) { + self.0 + .insert("X11Forwarding".to_string(), Self::yes_value(yes)); + } + + pub fn set_use_privilege_separation(&mut self, yes: bool) { + self.0 + .insert("UsePrivilegeSeparation".to_string(), Self::yes_value(yes)); + } + + pub fn set_print_motd(&mut self, yes: bool) { + self.0.insert("PrintMotd".to_string(), Self::yes_value(yes)); + } + + pub fn set_permit_tunnel(&mut self, yes: bool) { + self.0 + .insert("PermitTunnel".to_string(), Self::yes_value(yes)); + } + + pub fn set_kbd_interactive_authentication(&mut self, yes: bool) { + self.0.insert( + "KbdInteractiveAuthentication".to_string(), + Self::yes_value(yes), + ); + } + + pub fn set_allow_tcp_forwarding(&mut self, yes: bool) { + self.0 + .insert("AllowTcpForwarding".to_string(), Self::yes_value(yes)); + } + + pub fn set_max_startups(&mut self, start: u16, rate_full: Option<(u16, u16)>) { + let value = format!( + "{}{}", + start, + rate_full + .map(|(r, f)| format!(":{}:{}", r, f)) + .unwrap_or_default(), + ); + + self.0.insert("MaxStartups".to_string(), vec![value]); + } + + pub fn set_strict_modes(&mut self, yes: bool) { + self.0 + .insert("StrictModes".to_string(), Self::yes_value(yes)); + } + + fn yes_value(yes: bool) -> Vec { + vec![Self::yes_string(yes)] + } + + fn yes_string(yes: bool) -> String { + Self::yes_str(yes).to_string() + } + + const fn yes_str(yes: bool) -> &'static str { + if yes { + "yes" + } else { + "no" + } + } +} + +impl fmt::Display for SshdConfig { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for (keyword, values) in self.0.iter() { + writeln!( + f, + "{} {}", + keyword, + values + .iter() + .map(|v| { + let v = v.trim(); + if v.contains(|c: char| c.is_whitespace()) { + format!("\"{}\"", v) + } else { + v.to_string() + } + }) + .collect::>() + .join(" ") + )?; + } + Ok(()) + } +} + +/// Context for some sshd instance +pub struct Sshd { + child: Child, + + /// Port that sshd is listening on + pub port: u16, + + /// Temporary directory used to hold resources for sshd such as its config, keys, and log + pub tmp: TempDir, +} + +impl Sshd { + pub fn spawn(mut config: SshdConfig) -> Result> { + let tmp = TempDir::new()?; + + // Ensure that everything needed for interacting with ssh-agent is set + SshAgent::update_tests_with_shell_env()?; + + // ssh-keygen -t rsa -f $ROOT/id_rsa -N "" -q + let id_rsa_file = tmp.child("id_rsa"); + assert!( + SshKeygen::generate_rsa(id_rsa_file.path(), "")?, + "Failed to ssh-keygen id_rsa" + ); + + // cp $ROOT/id_rsa.pub $ROOT/authorized_keys + let authorized_keys_file = tmp.child("authorized_keys"); + std::fs::copy( + id_rsa_file.path().with_extension("pub"), + authorized_keys_file.path(), + )?; + + // ssh-keygen -t rsa -f $ROOT/ssh_host_rsa_key -N "" -q + let ssh_host_rsa_key_file = tmp.child("ssh_host_rsa_key"); + assert!( + SshKeygen::generate_rsa(ssh_host_rsa_key_file.path(), "")?, + "Failed to ssh-keygen ssh_host_rsa_key" + ); + + config.set_authorized_keys_file(id_rsa_file.path().with_extension("pub")); + config.set_host_key(ssh_host_rsa_key_file.path()); + + let sshd_pid_file = tmp.child("sshd.pid"); + config.set_pid_file(sshd_pid_file.path()); + + // Generate $ROOT/sshd_config based on config + let sshd_config_file = tmp.child("sshd_config"); + sshd_config_file.write_str(&config.to_string())?; + + let sshd_log_file = tmp.child("sshd.log"); + + let (child, port) = Self::try_spawn_next(sshd_config_file.path(), sshd_log_file.path()) + .expect("No open port available for sshd"); + + Ok(Self { child, port, tmp }) + } + + fn try_spawn_next( + config_path: impl AsRef, + log_path: impl AsRef, + ) -> io::Result<(Child, u16)> { + static PORT: AtomicU16 = AtomicU16::new(PORT_RANGE.0); + + loop { + let port = PORT.fetch_add(1, Ordering::Relaxed); + + match Self::try_spawn(port, config_path.as_ref(), log_path.as_ref()) { + // If successful, return our spawned server child process + Ok(Ok(child)) => break Ok((child, port)), + + // If the server died when spawned and we reached the final port, we want to exit + Ok(Err((code, msg))) if port == PORT_RANGE.1 => { + break Err(io::Error::new( + io::ErrorKind::Other, + format!( + "{} failed [{}]: {}", + BIN_PATH_STR, + code.map(|x| x.to_string()) + .unwrap_or_else(|| String::from("???")), + msg + ), + )) + } + + // If we've reached the final port in our range to try, we want to exit + Err(x) if port == PORT_RANGE.1 => break Err(x), + + // Otherwise, try next port + Err(_) | Ok(Err(_)) => continue, + } + } + } + + fn try_spawn( + port: u16, + config_path: impl AsRef, + log_path: impl AsRef, + ) -> io::Result, String)>> { + let mut child = Command::new(BIN_PATH_STR) + .arg("-D") + .arg("-p") + .arg(port.to_string()) + .arg("-f") + .arg(config_path.as_ref()) + .arg("-E") + .arg(log_path.as_ref()) + .spawn()?; + + // Pause for a little bit to make sure that the server didn't die due to an error + thread::sleep(Duration::from_millis(100)); + + if let Some(exit_status) = child.try_wait()? { + let output = child.wait_with_output()?; + Ok(Err(( + exit_status.code(), + format!( + "{}\n{}", + String::from_utf8(output.stdout).unwrap(), + String::from_utf8(output.stderr).unwrap(), + ), + ))) + } else { + Ok(Ok(child)) + } + } +} + +impl Drop for Sshd { + /// Kills server upon drop + fn drop(&mut self) { + let _ = self.child.kill(); + } +} + +#[fixture] +pub fn logger() -> &'static flexi_logger::LoggerHandle { + static LOGGER: OnceCell = OnceCell::new(); + + LOGGER.get_or_init(|| { + // flexi_logger::Logger::try_with_str("off, distant_core=trace, distant_ssh2=trace") + flexi_logger::Logger::try_with_str("off, distant_core=warn, distant_ssh2=warn") + .expect("Failed to load env") + .start() + .expect("Failed to start logger") + }) +} + +#[fixture] +pub fn sshd() -> &'static Sshd { + static SSHD: OnceCell = OnceCell::new(); + + SSHD.get_or_init(|| Sshd::spawn(Default::default()).unwrap()) +} + +#[fixture] +pub async fn session(sshd: &'_ Sshd, _logger: &'_ flexi_logger::LoggerHandle) -> Session { + let port = sshd.port; + + Ssh2Session::connect( + "127.0.0.1", + Ssh2SessionOpts { + port: Some(port), + identity_files: vec![sshd.tmp.child("id_rsa").path().to_path_buf()], + identities_only: Some(true), + user: Some(USERNAME.to_string()), + user_known_hosts_files: vec![sshd.tmp.child("known_hosts").path().to_path_buf()], + ..Default::default() + }, + ) + .unwrap() + .authenticate(Ssh2AuthHandler { + on_authenticate: Box::new(|ev| { + println!("on_authenticate: {:?}", ev); + Ok(vec![String::new(); ev.prompts.len()]) + }), + on_host_verify: Box::new(|host| { + println!("on_host_verify: {}", host); + Ok(true) + }), + ..Default::default() + }) + .await + .unwrap() +} diff --git a/src/constants.rs b/src/constants.rs index 97e2afd..1c690e8 100644 --- a/src/constants.rs +++ b/src/constants.rs @@ -1,3 +1,4 @@ +use once_cell::sync::Lazy; use std::{env, path::PathBuf}; /// Represents the maximum size (in bytes) that data will be read from pipes @@ -14,15 +15,16 @@ pub const SERVER_CONN_MSG_CAPACITY: usize = 10000; /// before failing (0 meaning indefinitely) pub const TIMEOUT: usize = 15000; -lazy_static::lazy_static! { - pub static ref TIMEOUT_STR: String = TIMEOUT.to_string(); - pub static ref SERVER_CONN_MSG_CAPACITY_STR: String = SERVER_CONN_MSG_CAPACITY.to_string(); +pub static TIMEOUT_STR: Lazy = Lazy::new(|| TIMEOUT.to_string()); +pub static SERVER_CONN_MSG_CAPACITY_STR: Lazy = + Lazy::new(|| SERVER_CONN_MSG_CAPACITY.to_string()); - /// Represents the path to the global session file - pub static ref SESSION_FILE_PATH: PathBuf = env::temp_dir().join("distant.session"); - pub static ref SESSION_FILE_PATH_STR: String = SESSION_FILE_PATH.to_string_lossy().to_string(); +/// Represents the path to the global session file +pub static SESSION_FILE_PATH: Lazy = Lazy::new(|| env::temp_dir().join("distant.session")); +pub static SESSION_FILE_PATH_STR: Lazy = + Lazy::new(|| SESSION_FILE_PATH.to_string_lossy().to_string()); - /// Represents the path to a socket to communicate instead of a session file - pub static ref SESSION_SOCKET_PATH: PathBuf = env::temp_dir().join("distant.sock"); - pub static ref SESSION_SOCKET_PATH_STR: String = SESSION_SOCKET_PATH.to_string_lossy().to_string(); -} +/// Represents the path to a socket to communicate instead of a session file +pub static SESSION_SOCKET_PATH: Lazy = Lazy::new(|| env::temp_dir().join("distant.sock")); +pub static SESSION_SOCKET_PATH_STR: Lazy = + Lazy::new(|| SESSION_SOCKET_PATH.to_string_lossy().to_string()); diff --git a/src/opt.rs b/src/opt.rs index 733bb1c..1aab2e4 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -7,7 +7,7 @@ use crate::{ }; use derive_more::{Display, Error, From, IsVariant}; use distant_core::{PortRange, RequestData}; -use lazy_static::lazy_static; +use once_cell::sync::Lazy; use std::{ env, net::{AddrParseError, IpAddr, Ipv4Addr, Ipv6Addr}, @@ -18,9 +18,7 @@ use std::{ use structopt::StructOpt; use strum::{EnumString, EnumVariantNames, IntoStaticStr, VariantNames}; -lazy_static! { - static ref USERNAME: String = whoami::username(); -} +static USERNAME: Lazy = Lazy::new(whoami::username); /// Options and commands to apply to binary #[derive(Debug, StructOpt)] @@ -123,6 +121,22 @@ pub struct SessionOpt { pub session_socket: PathBuf, } +/// Contains options related ssh +#[derive(Clone, Debug, StructOpt)] +pub struct SshConnectionOpts { + /// Host to use for connection to when using SSH method + #[structopt(name = "ssh-host", long, default_value = "localhost")] + pub host: String, + + /// Port to use for connection when using SSH method + #[structopt(name = "ssh-port", long, default_value = "22")] + pub port: u16, + + /// Alternative user for connection when using SSH method + #[structopt(name = "ssh-user", long)] + pub user: Option, +} + #[derive(Debug, StructOpt)] pub enum Subcommand { /// Performs some action on a remote machine @@ -165,6 +179,35 @@ impl Subcommand { } } +/// Represents the method to use in communicating with a remote machine +#[derive( + Copy, + Clone, + Debug, + Display, + PartialEq, + Eq, + IsVariant, + IntoStaticStr, + EnumString, + EnumVariantNames, +)] +#[strum(serialize_all = "snake_case")] +pub enum Method { + /// Launch/connect to a distant server running on a remote machine + Distant, + + /// Connect to an SSH server running on a remote machine + #[cfg(feature = "ssh2")] + Ssh, +} + +impl Default for Method { + fn default() -> Self { + Self::Distant + } +} + /// Represents the format for data communicated to & from the client #[derive( Copy, @@ -208,9 +251,20 @@ pub struct ActionSubcommand { )] pub format: Format, + /// Method to communicate with a remote machine + #[structopt( + short, + long, + case_insensitive = true, + default_value = Method::default().into(), + possible_values = Method::VARIANTS + )] + pub method: Method, + /// Represents the medium for retrieving a session for use in performing the action #[structopt( long, + case_insensitive = true, default_value = SessionInput::default().into(), possible_values = SessionInput::VARIANTS )] @@ -220,6 +274,10 @@ pub struct ActionSubcommand { #[structopt(flatten)] pub session_data: SessionOpt, + /// SSH connection settings when method is ssh + #[structopt(flatten)] + pub ssh_connection: SshConnectionOpts, + /// If specified, commands to send are sent over stdin and responses are received /// over stdout (and stderr if mode is shell) #[structopt(short, long)] @@ -574,9 +632,20 @@ pub struct LspSubcommand { )] pub format: Format, + /// Method to communicate with a remote machine + #[structopt( + short, + long, + case_insensitive = true, + default_value = Method::default().into(), + possible_values = Method::VARIANTS + )] + pub method: Method, + /// Represents the medium for retrieving a session to use when running a remote LSP server #[structopt( long, + case_insensitive = true, default_value = SessionInput::default().into(), possible_values = SessionInput::VARIANTS )] @@ -586,6 +655,10 @@ pub struct LspSubcommand { #[structopt(flatten)] pub session_data: SessionOpt, + /// SSH connection settings when method is ssh + #[structopt(flatten)] + pub ssh_connection: SshConnectionOpts, + /// Command to run on the remote machine that represents an LSP server pub cmd: String, diff --git a/src/subcommand/action.rs b/src/subcommand/action.rs index ef826d8..31207bf 100644 --- a/src/subcommand/action.rs +++ b/src/subcommand/action.rs @@ -1,9 +1,10 @@ use crate::{ exit::{ExitCode, ExitCodeError}, link::RemoteProcessLink, - opt::{ActionSubcommand, CommonOpt, Format, SessionInput}, + opt::{ActionSubcommand, CommonOpt, Format}, output::ResponseOut, session::CliSession, + subcommand::CommandRunner, utils, }; use derive_more::{Display, Error, From}; @@ -52,17 +53,26 @@ pub fn run(cmd: ActionSubcommand, opt: CommonOpt) -> Result<(), Error> { } async fn run_async(cmd: ActionSubcommand, opt: CommonOpt) -> Result<(), Error> { + let method = cmd.method; + let ssh_connection = cmd.ssh_connection.clone(); + let session_input = cmd.session; let timeout = opt.to_timeout_duration(); let session_file = cmd.session_data.session_file.clone(); let session_socket = cmd.session_data.session_socket.clone(); - extract_session_and_start!( - cmd, - cmd.session, + + CommandRunner { + method, + ssh_connection, + session_input, session_file, session_socket, timeout, - |cmd, session, timeout, lsp_data| start(cmd, session, timeout, lsp_data) + } + .run( + |session, timeout, lsp_data| Box::pin(start(cmd, session, timeout, lsp_data)), + Error::Io, ) + .await } async fn start( diff --git a/src/subcommand/lsp.rs b/src/subcommand/lsp.rs index 25b0426..f33b371 100644 --- a/src/subcommand/lsp.rs +++ b/src/subcommand/lsp.rs @@ -1,7 +1,8 @@ use crate::{ exit::{ExitCode, ExitCodeError}, link::RemoteProcessLink, - opt::{CommonOpt, LspSubcommand, SessionInput}, + opt::{CommonOpt, LspSubcommand}, + subcommand::CommandRunner, utils, }; use derive_more::{Display, Error, From}; @@ -40,17 +41,26 @@ pub fn run(cmd: LspSubcommand, opt: CommonOpt) -> Result<(), Error> { } async fn run_async(cmd: LspSubcommand, opt: CommonOpt) -> Result<(), Error> { + let method = cmd.method; let timeout = opt.to_timeout_duration(); + let ssh_connection = cmd.ssh_connection.clone(); + let session_input = cmd.session; let session_file = cmd.session_data.session_file.clone(); let session_socket = cmd.session_data.session_socket.clone(); - extract_session_and_start!( - cmd, - cmd.session, + + CommandRunner { + method, + ssh_connection, + session_input, session_file, session_socket, timeout, - |cmd, session, _, lsp_data| start(cmd, session, lsp_data) + } + .run( + |session, _, lsp_data| Box::pin(start(cmd, session, lsp_data)), + Error::Io, ) + .await } async fn start( diff --git a/src/subcommand/mod.rs b/src/subcommand/mod.rs index a323804..2b221df 100644 --- a/src/subcommand/mod.rs +++ b/src/subcommand/mod.rs @@ -1,80 +1,166 @@ -macro_rules! extract_session_and_start { - ( - $cmd:expr, - $session_ty:expr, - $session_file:expr, - $session_socket:expr, - $timeout:expr, - $start:expr - ) => {{ - use distant_core::{PlainCodec, SessionInfo, SessionInfoFile, XChaCha20Poly1305Codec}; - match $session_ty { - SessionInput::Environment => { - let info = SessionInfo::from_environment()?; - let addr = info.to_socket_addr().await?; - let codec = XChaCha20Poly1305Codec::from(info.key); - $start( - $cmd, - Session::tcp_connect_timeout(addr, codec, $timeout).await?, - $timeout, - None, +use crate::opt::{Method, SessionInput, SshConnectionOpts}; +use distant_core::{ + LspData, PlainCodec, Session, SessionInfo, SessionInfoFile, XChaCha20Poly1305Codec, +}; +use std::{ + future::Future, + io, + net::SocketAddr, + path::{Path, PathBuf}, + pin::Pin, + time::Duration, +}; + +pub mod action; +pub mod launch; +pub mod listen; +pub mod lsp; + +struct CommandRunner { + method: Method, + ssh_connection: SshConnectionOpts, + session_input: SessionInput, + session_file: PathBuf, + session_socket: PathBuf, + timeout: Duration, +} + +impl CommandRunner { + async fn run(self, start: F1, wrap_err: F2) -> Result<(), E> + where + F1: FnOnce( + Session, + Duration, + Option, + ) -> Pin>>>, + F2: Fn(io::Error) -> E + Copy, + E: std::error::Error, + { + let CommandRunner { + method, + ssh_connection, + session_input, + session_file, + session_socket, + timeout, + } = self; + + let (session, lsp_data) = match method { + #[cfg(feature = "ssh2")] + Method::Ssh => { + use distant_ssh2::{Ssh2Session, Ssh2SessionOpts}; + let SshConnectionOpts { host, port, user } = ssh_connection; + + let session = Ssh2Session::connect( + host, + Ssh2SessionOpts { + port: Some(port), + user, + ..Default::default() + }, ) + .map_err(wrap_err)? + .authenticate(Default::default()) .await + .map_err(wrap_err)?; + + (session, None) } - SessionInput::File => { - let info: SessionInfo = SessionInfoFile::load_from($session_file).await?.into(); - let addr = info.to_socket_addr().await?; - let codec = XChaCha20Poly1305Codec::from(info.key); - $start( - $cmd, - Session::tcp_connect_timeout(addr, codec, $timeout).await?, - $timeout, - None, - ) - .await + + Method::Distant => { + let params = retrieve_session_params(session_input, session_file, session_socket) + .await + .map_err(wrap_err)?; + match params { + SessionParams::Tcp { + addr, + codec, + lsp_data, + } => { + let session = Session::tcp_connect_timeout(addr, codec, timeout) + .await + .map_err(wrap_err)?; + (session, lsp_data) + } + SessionParams::Socket { path, codec } => { + let session = Session::unix_connect_timeout(path, codec, timeout) + .await + .map_err(wrap_err)?; + (session, None) + } + } } - SessionInput::Pipe => { - let info = SessionInfo::from_stdin()?; - let addr = info.to_socket_addr().await?; - let codec = XChaCha20Poly1305Codec::from(info.key); - $start( - $cmd, - Session::tcp_connect_timeout(addr, codec, $timeout).await?, - $timeout, - None, - ) - .await + }; + + start(session, timeout, lsp_data).await + } +} + +enum SessionParams { + Tcp { + addr: SocketAddr, + codec: XChaCha20Poly1305Codec, + lsp_data: Option, + }, + Socket { + path: PathBuf, + codec: PlainCodec, + }, +} + +async fn retrieve_session_params( + session_input: SessionInput, + session_file: impl AsRef, + session_socket: impl AsRef, +) -> io::Result { + Ok(match session_input { + SessionInput::Environment => { + let info = SessionInfo::from_environment()?; + let addr = info.to_socket_addr().await?; + let codec = XChaCha20Poly1305Codec::from(info.key); + SessionParams::Tcp { + addr, + codec, + lsp_data: None, } - SessionInput::Lsp => { - let mut data = LspData::from_buf_reader(&mut std::io::stdin().lock()) - .map_err(io::Error::from)?; - let info = data.take_session_info().map_err(io::Error::from)?; - let addr = info.to_socket_addr().await?; - let codec = XChaCha20Poly1305Codec::from(info.key); - $start( - $cmd, - Session::tcp_connect_timeout(addr, codec, $timeout).await?, - $timeout, - Some(data), - ) - .await + } + SessionInput::File => { + let info: SessionInfo = SessionInfoFile::load_from(session_file).await?.into(); + let addr = info.to_socket_addr().await?; + let codec = XChaCha20Poly1305Codec::from(info.key); + SessionParams::Tcp { + addr, + codec, + lsp_data: None, } - #[cfg(unix)] - SessionInput::Socket => { - $start( - $cmd, - Session::unix_connect_timeout($session_socket, PlainCodec::new(), $timeout) - .await?, - $timeout, - None, - ) - .await + } + SessionInput::Pipe => { + let info = SessionInfo::from_stdin()?; + let addr = info.to_socket_addr().await?; + let codec = XChaCha20Poly1305Codec::from(info.key); + SessionParams::Tcp { + addr, + codec, + lsp_data: None, } } - }}; + SessionInput::Lsp => { + let mut data = + LspData::from_buf_reader(&mut io::stdin().lock()).map_err(io::Error::from)?; + let info = data.take_session_info().map_err(io::Error::from)?; + let addr = info.to_socket_addr().await?; + let codec = XChaCha20Poly1305Codec::from(info.key); + SessionParams::Tcp { + addr, + codec, + lsp_data: Some(data), + } + } + #[cfg(unix)] + SessionInput::Socket => { + let path = session_socket.as_ref().to_path_buf(); + let codec = PlainCodec::new(); + SessionParams::Socket { path, codec } + } + }) } - -pub mod action; -pub mod launch; -pub mod listen; -pub mod lsp; diff --git a/tests/cli/action/proc_run.rs b/tests/cli/action/proc_run.rs index e9a6be2..40776cb 100644 --- a/tests/cli/action/proc_run.rs +++ b/tests/cli/action/proc_run.rs @@ -9,52 +9,67 @@ use distant_core::{ data::{Error, ErrorKind}, Request, RequestData, Response, ResponseData, }; +use once_cell::sync::Lazy; use rstest::*; use std::{io::Write, time::Duration}; -lazy_static::lazy_static! { - static ref TEMP_SCRIPT_DIR: assert_fs::TempDir = assert_fs::TempDir::new().unwrap(); - static ref SCRIPT_RUNNER: String = String::from("bash"); +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| assert_fs::TempDir::new().unwrap()); +static SCRIPT_RUNNER: Lazy = Lazy::new(|| String::from("bash")); - static ref ECHO_ARGS_TO_STDOUT_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); - script.write_str(indoc::indoc!(r#" +static ECHO_ARGS_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash - printf "%s" "$@" - "#)).unwrap(); - script - }; - - static ref ECHO_ARGS_TO_STDERR_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); - script.write_str(indoc::indoc!(r#" + printf "%s" "$*" + "# + )) + .unwrap(); + script +}); + +static ECHO_ARGS_TO_STDERR_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_args_to_stderr.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash - printf "%s" "$@" 1>&2 - "#)).unwrap(); - script - }; - - static ref ECHO_STDIN_TO_STDOUT_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); - script.write_str(indoc::indoc!(r#" + printf "%s" "$*" 1>&2 + "# + )) + .unwrap(); + script +}); + +static ECHO_STDIN_TO_STDOUT_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("echo_stdin_to_stdout.sh"); + script + .write_str(indoc::indoc!( + r#" #/usr/bin/env bash while IFS= read; do echo "$REPLY"; done - "#)).unwrap(); - script - }; - - static ref EXIT_CODE_SH: assert_fs::fixture::ChildPath = { - let script = TEMP_SCRIPT_DIR.child("exit_code.sh"); - script.write_str(indoc::indoc!(r#" + "# + )) + .unwrap(); + script +}); + +static EXIT_CODE_SH: Lazy = Lazy::new(|| { + let script = TEMP_SCRIPT_DIR.child("exit_code.sh"); + script + .write_str(indoc::indoc!( + r#" #!/usr/bin/env bash exit "$1" - "#)).unwrap(); - script - }; + "# + )) + .unwrap(); + script +}); - static ref DOES_NOT_EXIST_BIN: assert_fs::fixture::ChildPath = - TEMP_SCRIPT_DIR.child("does_not_exist_bin"); -} +static DOES_NOT_EXIST_BIN: Lazy = + Lazy::new(|| TEMP_SCRIPT_DIR.child("does_not_exist_bin")); macro_rules! next_two_msgs { ($rx:expr) => {{ diff --git a/tests/cli/fixtures.rs b/tests/cli/fixtures.rs index 609792d..166eb7f 100644 --- a/tests/cli/fixtures.rs +++ b/tests/cli/fixtures.rs @@ -1,6 +1,7 @@ use crate::cli::utils; use assert_cmd::Command; use distant_core::*; +use once_cell::sync::OnceCell; use rstest::*; use std::{ffi::OsStr, net::SocketAddr, thread}; use tokio::{runtime::Runtime, sync::mpsc}; @@ -86,11 +87,9 @@ impl Drop for DistantServerCtx { #[fixture] pub fn ctx() -> &'static DistantServerCtx { - lazy_static::lazy_static! { - static ref CTX: DistantServerCtx = DistantServerCtx::initialize(); - } + static CTX: OnceCell = OnceCell::new(); - &CTX + CTX.get_or_init(DistantServerCtx::initialize) } #[fixture] diff --git a/tests/cli/utils.rs b/tests/cli/utils.rs index dd477d6..70588f8 100644 --- a/tests/cli/utils.rs +++ b/tests/cli/utils.rs @@ -1,4 +1,5 @@ use crate::cli::fixtures::DistantServerCtx; +use once_cell::sync::Lazy; use predicates::prelude::*; use std::{ env, io, @@ -8,11 +9,9 @@ use std::{ time::{Duration, Instant}, }; -lazy_static::lazy_static! { - /// Predicate that checks for a single line that is a failure - pub static ref FAILURE_LINE: predicates::str::RegexPredicate = - regex_pred(r"^Failed \(.*\): '.*'\.\n$"); -} +/// Predicate that checks for a single line that is a failure +pub static FAILURE_LINE: Lazy = + Lazy::new(|| regex_pred(r"^Failed \(.*\): '.*'\.\n$")); /// Produces a regex predicate using the given string pub fn regex_pred(s: &str) -> predicates::str::RegexPredicate {