From 16bed4690b74c787054f71c89114089a73ad0f58 Mon Sep 17 00:00:00 2001 From: Chip Senkbeil Date: Wed, 6 Oct 2021 23:17:07 -0500 Subject: [PATCH] Add lua lib & support compiling distant cli on windows (#59) * Update distant-ssh2 with new changes to wezterm-ssh * Implement lua module (distant-lua) * Implement tests for lua module (distant-lua-tests) * Add untested windows daemon support * distant binary now compiles on windows * Split up Github actions for Windows, MacOS, and Linux into individual yaml files --- .cargo/{config => config.toml} | 0 .github/workflows/ci-all.yml | 55 +++ .github/workflows/{ci.yml => ci-linux.yml} | 69 +-- .github/workflows/ci-macos.yml | 68 +++ .github/workflows/ci-windows.yml | 80 ++++ Cargo.lock | 146 +++++- Cargo.toml | 6 +- distant-core/Cargo.toml | 1 + distant-core/src/client/lsp/mod.rs | 83 +++- distant-core/src/client/process.rs | 47 +- distant-core/src/client/session/ext.rs | 8 +- distant-core/src/constants.rs | 3 + distant-core/src/net/mod.rs | 11 +- distant-lua-tests/.cargo/config.toml | 8 + distant-lua-tests/Cargo.toml | 27 ++ distant-lua-tests/README.md | 34 ++ distant-lua-tests/tests/common/fixtures.rs | 74 +++ distant-lua-tests/tests/common/lua.rs | 41 ++ distant-lua-tests/tests/common/mod.rs | 4 + distant-lua-tests/tests/common/poll.rs | 17 + distant-lua-tests/tests/common/session.rs | 41 ++ distant-lua-tests/tests/lib.rs | 2 + .../tests/lua/async/append_file.rs | 79 ++++ .../tests/lua/async/append_file_text.rs | 79 ++++ distant-lua-tests/tests/lua/async/copy.rs | 210 +++++++++ .../tests/lua/async/create_dir.rs | 129 ++++++ distant-lua-tests/tests/lua/async/exists.rs | 73 +++ distant-lua-tests/tests/lua/async/metadata.rs | 238 ++++++++++ distant-lua-tests/tests/lua/async/mod.rs | 14 + distant-lua-tests/tests/lua/async/read_dir.rs | 357 ++++++++++++++ .../tests/lua/async/read_file.rs | 79 ++++ .../tests/lua/async/read_file_text.rs | 74 +++ distant-lua-tests/tests/lua/async/remove.rs | 145 ++++++ distant-lua-tests/tests/lua/async/rename.rs | 126 +++++ distant-lua-tests/tests/lua/async/spawn.rs | 437 ++++++++++++++++++ .../tests/lua/async/write_file.rs | 79 ++++ .../tests/lua/async/write_file_text.rs | 79 ++++ distant-lua-tests/tests/lua/mod.rs | 2 + .../tests/lua/sync/append_file.rs | 60 +++ .../tests/lua/sync/append_file_text.rs | 60 +++ distant-lua-tests/tests/lua/sync/copy.rs | 161 +++++++ .../tests/lua/sync/create_dir.rs | 91 ++++ distant-lua-tests/tests/lua/sync/exists.rs | 43 ++ distant-lua-tests/tests/lua/sync/metadata.rs | 152 ++++++ distant-lua-tests/tests/lua/sync/mod.rs | 14 + distant-lua-tests/tests/lua/sync/read_dir.rs | 249 ++++++++++ distant-lua-tests/tests/lua/sync/read_file.rs | 51 ++ .../tests/lua/sync/read_file_text.rs | 45 ++ distant-lua-tests/tests/lua/sync/remove.rs | 94 ++++ distant-lua-tests/tests/lua/sync/rename.rs | 97 ++++ distant-lua-tests/tests/lua/sync/spawn.rs | 288 ++++++++++++ .../tests/lua/sync/write_file.rs | 60 +++ .../tests/lua/sync/write_file_text.rs | 60 +++ distant-lua/.cargo/config.toml | 11 + distant-lua/Cargo.toml | 41 ++ distant-lua/README.md | 65 +++ distant-lua/src/lib.rs | 31 ++ distant-lua/src/log.rs | 114 +++++ distant-lua/src/runtime.rs | 38 ++ distant-lua/src/session.rs | 280 +++++++++++ distant-lua/src/session/api.rs | 187 ++++++++ distant-lua/src/session/opts.rs | 208 +++++++++ distant-lua/src/session/proc.rs | 194 ++++++++ distant-lua/src/utils.rs | 113 +++++ distant-ssh2/Cargo.toml | 1 + distant-ssh2/src/handler.rs | 81 ++-- distant-ssh2/src/lib.rs | 155 ++++++- distant-ssh2/tests/sshd.rs | 34 +- scripts/mac-m1-cross-build.sh | 35 +- scripts/release.sh | 1 + src/opt.rs | 16 +- src/subcommand/launch.rs | 69 +-- src/subcommand/listen.rs | 56 ++- src/subcommand/mod.rs | 19 +- 74 files changed, 6093 insertions(+), 206 deletions(-) rename .cargo/{config => config.toml} (100%) create mode 100644 .github/workflows/ci-all.yml rename .github/workflows/{ci.yml => ci-linux.yml} (50%) create mode 100644 .github/workflows/ci-macos.yml create mode 100644 .github/workflows/ci-windows.yml create mode 100644 distant-lua-tests/.cargo/config.toml create mode 100644 distant-lua-tests/Cargo.toml create mode 100644 distant-lua-tests/README.md create mode 100644 distant-lua-tests/tests/common/fixtures.rs create mode 100644 distant-lua-tests/tests/common/lua.rs create mode 100644 distant-lua-tests/tests/common/mod.rs create mode 100644 distant-lua-tests/tests/common/poll.rs create mode 100644 distant-lua-tests/tests/common/session.rs create mode 100644 distant-lua-tests/tests/lib.rs create mode 100644 distant-lua-tests/tests/lua/async/append_file.rs create mode 100644 distant-lua-tests/tests/lua/async/append_file_text.rs create mode 100644 distant-lua-tests/tests/lua/async/copy.rs create mode 100644 distant-lua-tests/tests/lua/async/create_dir.rs create mode 100644 distant-lua-tests/tests/lua/async/exists.rs create mode 100644 distant-lua-tests/tests/lua/async/metadata.rs create mode 100644 distant-lua-tests/tests/lua/async/mod.rs create mode 100644 distant-lua-tests/tests/lua/async/read_dir.rs create mode 100644 distant-lua-tests/tests/lua/async/read_file.rs create mode 100644 distant-lua-tests/tests/lua/async/read_file_text.rs create mode 100644 distant-lua-tests/tests/lua/async/remove.rs create mode 100644 distant-lua-tests/tests/lua/async/rename.rs create mode 100644 distant-lua-tests/tests/lua/async/spawn.rs create mode 100644 distant-lua-tests/tests/lua/async/write_file.rs create mode 100644 distant-lua-tests/tests/lua/async/write_file_text.rs create mode 100644 distant-lua-tests/tests/lua/mod.rs create mode 100644 distant-lua-tests/tests/lua/sync/append_file.rs create mode 100644 distant-lua-tests/tests/lua/sync/append_file_text.rs create mode 100644 distant-lua-tests/tests/lua/sync/copy.rs create mode 100644 distant-lua-tests/tests/lua/sync/create_dir.rs create mode 100644 distant-lua-tests/tests/lua/sync/exists.rs create mode 100644 distant-lua-tests/tests/lua/sync/metadata.rs create mode 100644 distant-lua-tests/tests/lua/sync/mod.rs create mode 100644 distant-lua-tests/tests/lua/sync/read_dir.rs create mode 100644 distant-lua-tests/tests/lua/sync/read_file.rs create mode 100644 distant-lua-tests/tests/lua/sync/read_file_text.rs create mode 100644 distant-lua-tests/tests/lua/sync/remove.rs create mode 100644 distant-lua-tests/tests/lua/sync/rename.rs create mode 100644 distant-lua-tests/tests/lua/sync/spawn.rs create mode 100644 distant-lua-tests/tests/lua/sync/write_file.rs create mode 100644 distant-lua-tests/tests/lua/sync/write_file_text.rs create mode 100644 distant-lua/.cargo/config.toml create mode 100644 distant-lua/Cargo.toml create mode 100644 distant-lua/README.md create mode 100644 distant-lua/src/lib.rs create mode 100644 distant-lua/src/log.rs create mode 100644 distant-lua/src/runtime.rs create mode 100644 distant-lua/src/session.rs create mode 100644 distant-lua/src/session/api.rs create mode 100644 distant-lua/src/session/opts.rs create mode 100644 distant-lua/src/session/proc.rs create mode 100644 distant-lua/src/utils.rs diff --git a/.cargo/config b/.cargo/config.toml similarity index 100% rename from .cargo/config rename to .cargo/config.toml diff --git a/.github/workflows/ci-all.yml b/.github/workflows/ci-all.yml new file mode 100644 index 0000000..8941cde --- /dev/null +++ b/.github/workflows/ci-all.yml @@ -0,0 +1,55 @@ +name: CI (All) + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + clippy: + name: Lint with clippy + runs-on: ubuntu-latest + env: + RUSTFLAGS: -Dwarnings + steps: + - uses: actions/checkout@v2 + - name: Install Rust (clippy) + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + components: clippy + - uses: Swatinem/rust-cache@v1 + - name: Check Cargo availability + run: cargo --version + - name: distant-core (all features) + run: cargo clippy -p distant-core --all-targets --verbose --all-features + - name: distant-ssh2 (all features) + run: cargo clippy -p distant-ssh2 --all-targets --verbose --all-features + - name: distant-lua (lua51 & vendored) + run: (cd distant-lua && cargo clippy --all-targets --verbose --no-default-features --features "lua51,vendored") + shell: bash + - name: distant-lua-tests (lua51 & vendored) + run: (cd distant-lua-tests && cargo clippy --tests --verbose --no-default-features --features "lua51,vendored") + shell: bash + - name: distant (all features) + run: cargo clippy --all-targets --verbose --all-features + + rustfmt: + name: Verify code formatting + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v2 + - name: Install Rust (rustfmt) + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: stable + components: rustfmt + - uses: Swatinem/rust-cache@v1 + - name: Check Cargo availability + run: cargo --version + - run: cargo fmt --all -- --check diff --git a/.github/workflows/ci.yml b/.github/workflows/ci-linux.yml similarity index 50% rename from .github/workflows/ci.yml rename to .github/workflows/ci-linux.yml index 65a880e..80999e6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci-linux.yml @@ -1,4 +1,4 @@ -name: CI +name: CI (Linux) on: push: @@ -17,8 +17,6 @@ jobs: 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 @@ -30,8 +28,6 @@ jobs: - uses: Swatinem/rust-cache@v1 - name: Check Cargo availability run: cargo --version - - uses: Vampire/setup-wsl@v1 - if: ${{ matrix.os == 'windows-latest' }} - uses: dorny/paths-filter@v2 id: changes with: @@ -44,6 +40,9 @@ jobs: - 'distant-core/**' ssh2: - 'distant-ssh2/**' + lua: + - 'distant-lua/**' + - 'distant-lua-tests/**' - name: Run core tests (default features) run: cargo test --verbose -p distant-core if: steps.changes.outputs.core == 'true' @@ -52,64 +51,22 @@ jobs: 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' + if: 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' + if: 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' + if: 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' + if: 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 - runs-on: ubuntu-latest - env: - RUSTFLAGS: -Dwarnings - steps: - - uses: actions/checkout@v2 - - name: Install Rust (clippy) - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - components: clippy - - uses: Swatinem/rust-cache@v1 - - name: Check Cargo availability - run: cargo --version - - run: cargo clippy --workspace --all-targets --verbose - - run: cargo clippy --workspace --all-targets --verbose --all-features - - rustfmt: - name: Verify code formatting - runs-on: ubuntu-latest - steps: - - uses: actions/checkout@v2 - - name: Install Rust (rustfmt) - uses: actions-rs/toolchain@v1 - with: - profile: minimal - toolchain: stable - components: rustfmt - - uses: Swatinem/rust-cache@v1 - - name: Check Cargo availability - run: cargo --version - - run: cargo fmt --all -- --check + if: steps.changes.outputs.cli == 'true' + - name: Run Lua tests + run: (cd distant-lua && cargo build) && (cd distant-lua-tests && cargo test --verbose) + shell: bash + if: steps.changes.outputs.lua == 'true' diff --git a/.github/workflows/ci-macos.yml b/.github/workflows/ci-macos.yml new file mode 100644 index 0000000..8203418 --- /dev/null +++ b/.github/workflows/ci-macos.yml @@ -0,0 +1,68 @@ +name: CI (MacOS) + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + tests: + name: "Test Rust ${{ matrix.rust }} on ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { rust: stable, os: macos-latest } + steps: + - uses: actions/checkout@v2 + - name: Install Rust ${{ matrix.rust }} + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + - uses: Swatinem/rust-cache@v1 + - name: Check Cargo availability + run: cargo --version + - uses: dorny/paths-filter@v2 + id: changes + with: + base: ${{ github.ref }} + filters: | + cli: + - 'src/**' + - 'Cargo.*' + core: + - 'distant-core/**' + ssh2: + - 'distant-ssh2/**' + lua: + - 'distant-lua/**' + - 'distant-lua-tests/**' + - 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: Run ssh2 tests (default features) + run: cargo test --verbose -p distant-ssh2 + if: steps.changes.outputs.ssh2 == 'true' + - name: Run ssh2 tests (all features) + run: cargo test --verbose --all-features -p distant-ssh2 + if: steps.changes.outputs.ssh2 == 'true' + - name: Run CLI tests + run: cargo test --verbose + shell: bash + if: steps.changes.outputs.cli == 'true' + - name: Run CLI tests (no default features) + run: cargo test --verbose --no-default-features + shell: bash + if: steps.changes.outputs.cli == 'true' + - name: Run Lua tests + run: (cd distant-lua && cargo build) && (cd distant-lua-tests && cargo test --verbose) + shell: bash + if: steps.changes.outputs.lua == 'true' diff --git a/.github/workflows/ci-windows.yml b/.github/workflows/ci-windows.yml new file mode 100644 index 0000000..23cf3a4 --- /dev/null +++ b/.github/workflows/ci-windows.yml @@ -0,0 +1,80 @@ +name: CI (Windows) + +on: + push: + branches: + - master + pull_request: + branches: + - master + +jobs: + tests: + name: "Test Rust ${{ matrix.rust }} on ${{ matrix.os }}" + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + include: + - { rust: stable, os: windows-latest, target: x86_64-pc-windows-msvc } + steps: + - uses: actions/checkout@v2 + - name: Install Rust ${{ matrix.rust }} + uses: actions-rs/toolchain@v1 + with: + profile: minimal + toolchain: ${{ matrix.rust }} + target: ${{ matrix.target }} + - uses: Swatinem/rust-cache@v1 + - name: Check Cargo availability + run: cargo --version + - uses: Vampire/setup-wsl@v1 + - uses: dorny/paths-filter@v2 + id: changes + with: + base: ${{ github.ref }} + filters: | + cli: + - 'src/**' + - 'Cargo.*' + core: + - 'distant-core/**' + ssh2: + - 'distant-ssh2/**' + lua: + - 'distant-lua/**' + - 'distant-lua-tests/**' + - name: Run distant-core tests (default features) + run: cargo test --verbose -p distant-core + if: steps.changes.outputs.core == 'true' + - name: Run distant-core tests (all features) + run: cargo test --verbose --all-features -p distant-core + if: steps.changes.outputs.core == 'true' + - name: Build distant-ssh2 (default features) + run: cargo build --verbose -p distant-ssh2 + if: steps.changes.outputs.ssh2 == 'true' + - name: Build distant-ssh2 (all features) + run: cargo build --verbose --all-features -p distant-ssh2 + if: steps.changes.outputs.ssh2 == 'true' + - name: Build CLI + run: cargo build --verbose + shell: bash + if: steps.changes.outputs.cli == 'true' + - name: Build CLI (no default features) + run: cargo build --verbose --no-default-features + shell: bash + if: steps.changes.outputs.cli == 'true' + - uses: xpol/setup-lua@v0.3 + with: + lua-version: "5.1.5" + if: steps.changes.outputs.lua == 'true' + - name: Build Lua (Lua 5.1) + run: | + cd ${{ github.workspace }}\distant-lua + cargo build --verbose --no-default-features --features lua51 + shell: cmd + env: + LUA_INC: ${{ github.workspace }}\.lua\include + LUA_LIB: ${{ github.workspace }}\.lua\lib + LUA_LIB_NAME: lua + if: steps.changes.outputs.lua == 'true' diff --git a/Cargo.lock b/Cargo.lock index 180ef5c..ececef5 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -186,7 +186,7 @@ checksum = "e91831deabf0d6d7ec49552e489aed63b7456a7a3c46cff62adad428110b0af0" [[package]] name = "async_ossl" version = "0.1.0" -source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +source = "git+https://github.com/chipsenkbeil/wezterm#f25fbb737563666006c166233cab10daf853a3b1" dependencies = [ "openssl", ] @@ -280,6 +280,12 @@ version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "631ae5198c9be5e753e5cc215e1bd73c2b466a3565173db433f52bb9d3e66dba" +[[package]] +name = "camino" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52d74260d9bf6944e2208aa46841b4b8f0d7ffc0849a06837b2f510337f86b2b" + [[package]] name = "cc" version = "1.0.70" @@ -476,6 +482,40 @@ dependencies = [ "walkdir", ] +[[package]] +name = "distant-lua" +version = "0.15.0-alpha.6" +dependencies = [ + "distant-core", + "distant-ssh2", + "futures", + "log", + "mlua", + "once_cell", + "oorandom", + "paste", + "rstest", + "serde", + "simplelog", + "tokio", + "whoami", +] + +[[package]] +name = "distant-lua-tests" +version = "0.0.0" +dependencies = [ + "assert_fs", + "distant-core", + "futures", + "indoc", + "mlua", + "once_cell", + "predicates", + "rstest", + "tokio", +] + [[package]] name = "distant-ssh2" version = "0.15.0-alpha.6" @@ -494,6 +534,7 @@ dependencies = [ "rpassword", "rstest", "serde", + "shell-words", "smol", "tokio", "wezterm-ssh", @@ -512,6 +553,15 @@ version = "1.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +[[package]] +name = "erased-serde" +version = "0.3.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3de9ad4541d99dc22b59134e7ff8dc3d6c988c89ecd7324bf10a8362b07a2afa" +dependencies = [ + "serde", +] + [[package]] name = "event-listener" version = "2.5.1" @@ -530,7 +580,7 @@ dependencies = [ [[package]] name = "filedescriptor" version = "0.8.1" -source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +source = "git+https://github.com/chipsenkbeil/wezterm#f25fbb737563666006c166233cab10daf853a3b1" dependencies = [ "libc", "thiserror", @@ -935,6 +985,24 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "lua-src" +version = "543.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b72914332bf1ef0e1185b229135d639f11a4a8ccfd32852db8e52419c04c0247" +dependencies = [ + "cc", +] + +[[package]] +name = "luajit-src" +version = "210.2.0+resty5f13855" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49f85722ea9e022305a077b916c9271011a195ee8dc9b2b764fc78b0378e3b72" +dependencies = [ + "cc", +] + [[package]] name = "memchr" version = "2.4.1" @@ -963,6 +1031,42 @@ dependencies = [ "winapi", ] +[[package]] +name = "mlua" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10220b40602740bbb1bfa676bb477c7587ec1226d26f9a5f379192ea0a3e24f" +dependencies = [ + "bstr 0.2.16", + "cc", + "erased-serde", + "futures-core", + "futures-task", + "futures-util", + "lua-src", + "luajit-src", + "mlua_derive", + "num-traits", + "once_cell", + "pkg-config", + "serde", +] + +[[package]] +name = "mlua_derive" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1713774a29db53a48932596dc943439dd54eb56a9efaace716719cc10fa82d5b" +dependencies = [ + "itertools", + "once_cell", + "proc-macro-error", + "proc-macro2", + "quote", + "regex", + "syn", +] + [[package]] name = "normalize-line-endings" version = "0.3.0" @@ -1013,6 +1117,12 @@ version = "1.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "692fcb63b64b1758029e0a96ee63e049ce8c5948587f2f7208df04625e5f6b56" +[[package]] +name = "oorandom" +version = "11.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1bc2a289d34bd04a330323ac98a1b4bc82c9d9fcb1e66b63caa84da26b575" + [[package]] name = "opaque-debug" version = "0.3.0" @@ -1087,6 +1197,12 @@ dependencies = [ "winapi", ] +[[package]] +name = "paste" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf547ad0c65e31259204bd90935776d1c693cec2f4ff7abb7a1bbbd40dfe58" + [[package]] name = "pin-project-lite" version = "0.2.7" @@ -1132,7 +1248,7 @@ dependencies = [ [[package]] name = "portable-pty" version = "0.5.0" -source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +source = "git+https://github.com/chipsenkbeil/wezterm#f25fbb737563666006c166233cab10daf853a3b1" dependencies = [ "anyhow", "bitflags", @@ -1510,6 +1626,17 @@ dependencies = [ "libc", ] +[[package]] +name = "simplelog" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85d04ae642154220ef00ee82c36fb07853c10a4f2a0ca6719f9991211d2eb959" +dependencies = [ + "chrono", + "log", + "termcolor", +] + [[package]] name = "slab" version = "0.4.4" @@ -1643,6 +1770,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "termcolor" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dfed899f0eb03f32ee8c6a0aabdb8a7949659e3466561fc0adf54e26d88c5f4" +dependencies = [ + "winapi-util", +] + [[package]] name = "termios" version = "0.2.2" @@ -1917,11 +2053,13 @@ dependencies = [ [[package]] name = "wezterm-ssh" version = "0.2.0" -source = "git+https://github.com/chipsenkbeil/wezterm#5783332027c640d23001f3dad1ece433c424bb36" +source = "git+https://github.com/chipsenkbeil/wezterm#f25fbb737563666006c166233cab10daf853a3b1" dependencies = [ "anyhow", "async_ossl", "base64", + "bitflags", + "camino", "dirs-next", "filedescriptor", "filenamegen", diff --git a/Cargo.toml b/Cargo.toml index fcd7367..216d03c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -12,7 +12,7 @@ readme = "README.md" license = "MIT OR Apache-2.0" [workspace] -members = ["distant-core", "distant-ssh2"] +members = ["distant-core", "distant-lua", "distant-lua-tests", "distant-ssh2"] [profile.release] opt-level = 'z' @@ -30,7 +30,6 @@ ssh2 = ["distant-ssh2"] derive_more = { version = "0.99.16", default-features = false, features = ["display", "from", "error", "is_variant"] } distant-core = { version = "=0.15.0-alpha.6", path = "distant-core", features = ["structopt"] } flexi_logger = "0.18.0" -fork = "0.1.18" log = "0.4.14" once_cell = "1.8.0" rand = { version = "0.8.4", features = ["getrandom"] } @@ -43,6 +42,9 @@ whoami = "1.1.2" # Optional native SSH functionality distant-ssh2 = { version = "=0.15.0-alpha.6", path = "distant-ssh2", optional = true } +[target.'cfg(unix)'.dependencies] +fork = "0.1.18" + [dev-dependencies] assert_cmd = "2.0.0" assert_fs = "1.0.4" diff --git a/distant-core/Cargo.toml b/distant-core/Cargo.toml index 277edff..4623b98 100644 --- a/distant-core/Cargo.toml +++ b/distant-core/Cargo.toml @@ -2,6 +2,7 @@ name = "distant-core" description = "Core library for distant, enabling operation on a remote computer through file and process manipulation" categories = ["network-programming"] +keywords = ["api", "async"] version = "0.15.0-alpha.6" authors = ["Chip Senkbeil "] edition = "2018" diff --git a/distant-core/src/client/lsp/mod.rs b/distant-core/src/client/lsp/mod.rs index d8e2467..ee62a12 100644 --- a/distant-core/src/client/lsp/mod.rs +++ b/distant-core/src/client/lsp/mod.rs @@ -6,7 +6,10 @@ use std::{ io::{self, Cursor, Read}, ops::{Deref, DerefMut}, }; -use tokio::{sync::mpsc, task::JoinHandle}; +use tokio::{ + sync::mpsc::{self, error::TryRecvError}, + task::JoinHandle, +}; mod data; pub use data::*; @@ -74,18 +77,24 @@ impl RemoteLspStdin { Self { inner, buf: None } } - /// Writes data to the stdin of a specific remote process - pub async fn write(&mut self, data: &str) -> io::Result<()> { - // Create or insert into our buffer - match &mut self.buf { - Some(buf) => buf.push_str(data), - None => self.buf = Some(data.to_string()), + /// Tries to write data to the stdin of a specific remote process + pub fn try_write(&mut self, data: &str) -> io::Result<()> { + let queue = self.update_and_read_messages(data)?; + + // Process and then send out each LSP message in our queue + for mut data in queue { + // Convert distant:// to file:// + data.mut_content().convert_distant_scheme_to_local(); + data.refresh_content_length(); + self.inner.try_write(&data.to_string())?; } - // Read LSP messages from our internal buffer - let buf = self.buf.take().unwrap(); - let (remainder, queue) = read_lsp_messages(buf)?; - self.buf = remainder; + Ok(()) + } + + /// Writes data to the stdin of a specific remote process + pub async fn write(&mut self, data: &str) -> io::Result<()> { + let queue = self.update_and_read_messages(data)?; // Process and then send out each LSP message in our queue for mut data in queue { @@ -97,6 +106,30 @@ impl RemoteLspStdin { Ok(()) } + + fn update_and_read_messages(&mut self, data: &str) -> io::Result> { + // Create or insert into our buffer + match &mut self.buf { + Some(buf) => buf.push_str(data), + None => self.buf = Some(data.to_string()), + } + + // Read LSP messages from our internal buffer + let buf = self.buf.take().unwrap(); + match read_lsp_messages(&buf) { + // If we succeed, update buf with our remainder and return messages + Ok((remainder, queue)) => { + self.buf = remainder; + Ok(queue) + } + + // Otherwise, if failed, reset buf back to what it was + Err(x) => { + self.buf = Some(buf); + Err(x) + } + } + } } /// A handle to a remote LSP process' standard output (stdout) @@ -121,6 +154,18 @@ impl RemoteLspStdout { Self { read_task, rx } } + /// Tries to read a complete LSP message over stdout, returning `None` if no complete message + /// is available + pub fn try_read(&mut self) -> io::Result> { + match self.rx.try_recv() { + Ok(Ok(data)) => Ok(Some(data)), + Ok(Err(x)) => Err(x), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + + /// Reads a complete LSP message over stdout pub async fn read(&mut self) -> io::Result { self.rx .recv() @@ -158,6 +203,18 @@ impl RemoteLspStderr { Self { read_task, rx } } + /// Tries to read a complete LSP message over stderr, returning `None` if no complete message + /// is available + pub fn try_read(&mut self) -> io::Result> { + match self.rx.try_recv() { + Ok(Ok(data)) => Ok(Some(data)), + Ok(Err(x)) => Err(x), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + + /// Reads a complete LSP message over stderr pub async fn read(&mut self) -> io::Result { self.rx .recv() @@ -190,7 +247,7 @@ where // Read LSP messages from our internal buffer let buf = task_buf.take().unwrap(); - let (remainder, queue) = match read_lsp_messages(buf) { + let (remainder, queue) = match read_lsp_messages(&buf) { Ok(x) => x, Err(x) => { let _ = tx.send(Err(x)).await; @@ -218,7 +275,7 @@ where (read_task, rx) } -fn read_lsp_messages(input: String) -> io::Result<(Option, Vec)> { +fn read_lsp_messages(input: &str) -> io::Result<(Option, Vec)> { let mut queue = Vec::new(); // Continue to read complete messages from the input until we either fail to parse or we reach diff --git a/distant-core/src/client/process.rs b/distant-core/src/client/process.rs index 38ef355..5928748 100644 --- a/distant-core/src/client/process.rs +++ b/distant-core/src/client/process.rs @@ -1,6 +1,6 @@ use crate::{ client::{Mailbox, SessionChannel}, - constants::CLIENT_MAILBOX_CAPACITY, + constants::CLIENT_PIPE_CAPACITY, data::{Request, RequestData, ResponseData}, net::TransportError, }; @@ -8,7 +8,10 @@ use derive_more::{Display, Error, From}; use log::*; use tokio::{ io, - sync::mpsc, + sync::mpsc::{ + self, + error::{TryRecvError, TrySendError}, + }, task::{JoinError, JoinHandle}, }; @@ -105,9 +108,9 @@ impl RemoteProcess { }; // Create channels for our stdin/stdout/stderr - let (stdin_tx, stdin_rx) = mpsc::channel(CLIENT_MAILBOX_CAPACITY); - let (stdout_tx, stdout_rx) = mpsc::channel(CLIENT_MAILBOX_CAPACITY); - let (stderr_tx, stderr_rx) = mpsc::channel(CLIENT_MAILBOX_CAPACITY); + let (stdin_tx, stdin_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); + let (stdout_tx, stdout_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); + let (stderr_tx, stderr_rx) = mpsc::channel(CLIENT_PIPE_CAPACITY); // Used to terminate request task, either explicitly by the process or internally // by the response task when it terminates @@ -173,6 +176,15 @@ impl RemoteProcess { pub struct RemoteStdin(mpsc::Sender); impl RemoteStdin { + /// Tries to write to the stdin of the remote process + pub fn try_write(&mut self, data: impl Into) -> io::Result<()> { + match self.0.try_send(data.into()) { + Ok(data) => Ok(data), + Err(TrySendError::Full(_)) => Err(io::Error::from(io::ErrorKind::WouldBlock)), + Err(TrySendError::Closed(_)) => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + /// Writes data to the stdin of a specific remote process pub async fn write(&mut self, data: impl Into) -> io::Result<()> { self.0 @@ -180,6 +192,11 @@ impl RemoteStdin { .await .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x)) } + + /// Checks if stdin has been closed + pub fn is_closed(&self) -> bool { + self.0.is_closed() + } } /// A handle to a remote process' standard output (stdout) @@ -187,6 +204,16 @@ impl RemoteStdin { pub struct RemoteStdout(mpsc::Receiver); impl RemoteStdout { + /// Tries to receive latest stdout for a remote process, yielding `None` + /// if no stdout is available + pub fn try_read(&mut self) -> io::Result> { + match self.0.try_recv() { + Ok(data) => Ok(Some(data)), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + /// Retrieves the latest stdout for a specific remote process pub async fn read(&mut self) -> io::Result { self.0 @@ -201,6 +228,16 @@ impl RemoteStdout { pub struct RemoteStderr(mpsc::Receiver); impl RemoteStderr { + /// Tries to receive latest stderr for a remote process, yielding `None` + /// if no stderr is available + pub fn try_read(&mut self) -> io::Result> { + match self.0.try_recv() { + Ok(data) => Ok(Some(data)), + Err(TryRecvError::Empty) => Ok(None), + Err(TryRecvError::Disconnected) => Err(io::Error::from(io::ErrorKind::BrokenPipe)), + } + } + /// Retrieves the latest stderr for a specific remote process pub async fn read(&mut self) -> io::Result { self.0 diff --git a/distant-core/src/client/session/ext.rs b/distant-core/src/client/session/ext.rs index 48d5145..fb2701d 100644 --- a/distant-core/src/client/session/ext.rs +++ b/distant-core/src/client/session/ext.rs @@ -154,10 +154,10 @@ pub trait SessionChannelExt { macro_rules! make_body { ($self:expr, $tenant:expr, $data:expr, @ok) => { make_body!($self, $tenant, $data, |data| { - if data.is_ok() { - Ok(()) - } else { - Err(SessionChannelExtError::MismatchedResponse) + match data { + ResponseData::Ok => Ok(()), + ResponseData::Error(x) => Err(SessionChannelExtError::Failure(x)), + _ => Err(SessionChannelExtError::MismatchedResponse), } }) }; diff --git a/distant-core/src/constants.rs b/distant-core/src/constants.rs index ddeb26e..e293db8 100644 --- a/distant-core/src/constants.rs +++ b/distant-core/src/constants.rs @@ -1,6 +1,9 @@ /// Capacity associated with a client mailboxes for receiving multiple responses to a request pub const CLIENT_MAILBOX_CAPACITY: usize = 10000; +/// Capacity associated stdin, stdout, and stderr pipes receiving data from remote server +pub const CLIENT_PIPE_CAPACITY: usize = 10000; + /// Represents the maximum size (in bytes) that data will be read from pipes /// per individual `read` call /// diff --git a/distant-core/src/net/mod.rs b/distant-core/src/net/mod.rs index bd9a7d3..8ac958e 100644 --- a/distant-core/src/net/mod.rs +++ b/distant-core/src/net/mod.rs @@ -4,7 +4,7 @@ mod transport; use derive_more::{Display, Error}; pub use listener::{AcceptFuture, Listener, TransportListener}; use rand::{rngs::OsRng, RngCore}; -use std::fmt; +use std::{fmt, str::FromStr}; pub use transport::*; #[derive(Debug, Display, Error)] @@ -81,6 +81,15 @@ impl SecretKey { } } +impl FromStr for SecretKey { + type Err = SecretKeyError; + + fn from_str(s: &str) -> Result { + let bytes = hex::decode(s).map_err(|_| SecretKeyError)?; + Self::from_slice(&bytes) + } +} + pub trait UnprotectedToHexKey { fn unprotected_to_hex_key(&self) -> String; } diff --git a/distant-lua-tests/.cargo/config.toml b/distant-lua-tests/.cargo/config.toml new file mode 100644 index 0000000..6d87dde --- /dev/null +++ b/distant-lua-tests/.cargo/config.toml @@ -0,0 +1,8 @@ +[target.x86_64-apple-darwin] +rustflags = ["-C", "link-args=-rdynamic"] + +[target.aarch64-apple-darwin] +rustflags = ["-C", "link-args=-rdynamic"] + +[target.x86_64-unknown-linux-gnu] +rustflags = ["-C", "link-args=-rdynamic"] diff --git a/distant-lua-tests/Cargo.toml b/distant-lua-tests/Cargo.toml new file mode 100644 index 0000000..05a132a --- /dev/null +++ b/distant-lua-tests/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "distant-lua-tests" +description = "Tests for distant-lua crate" +version = "0.0.0" +authors = ["Chip Senkbeil "] +edition = "2018" +publish = false + +[features] +default = ["lua51", "vendored"] +lua54 = ["mlua/lua54"] +lua53 = ["mlua/lua53"] +lua52 = ["mlua/lua52"] +lua51 = ["mlua/lua51"] +luajit = ["mlua/luajit"] +vendored = ["mlua/vendored"] + +[dependencies] +assert_fs = "1.0.4" +distant-core = { path = "../distant-core" } +futures = "0.3.17" +indoc = "1.0.3" +mlua = { version = "0.6.5", features = ["async", "macros", "serialize"] } +once_cell = "1.8.0" +predicates = "2.0.2" +rstest = "0.11.0" +tokio = { version = "1.12.0", features = ["rt", "sync"] } diff --git a/distant-lua-tests/README.md b/distant-lua-tests/README.md new file mode 100644 index 0000000..ccbe56a --- /dev/null +++ b/distant-lua-tests/README.md @@ -0,0 +1,34 @@ +# Tests for Distant Lua (module) + +Contains tests for the **distant-lua** module. These tests must be in a +separate crate due to linking restrictions as described in +[khvzak/mlua#79](https://github.com/khvzak/mlua/issues/79). + +## Tests + +You must run these tests from within this directory, not from the root of the +repository. Additionally, you must build the Lua module **before** running +these tests! + +```bash +# From root of repository +(cd distant-lua && cargo build --release) +``` + +Running the tests themselves: + +```bash +# From root of repository +(cd distant-lua-tests && cargo test --release) +``` + +## License + +This project is licensed under either of + +Apache License, Version 2.0, (LICENSE-APACHE or +[apache-license][apache-license]) MIT license (LICENSE-MIT or +[mit-license][mit-license]) at your option. + +[apache-license]: http://www.apache.org/licenses/LICENSE-2.0 +[mit-license]: http://opensource.org/licenses/MIT diff --git a/distant-lua-tests/tests/common/fixtures.rs b/distant-lua-tests/tests/common/fixtures.rs new file mode 100644 index 0000000..92f843a --- /dev/null +++ b/distant-lua-tests/tests/common/fixtures.rs @@ -0,0 +1,74 @@ +use distant_core::*; +use once_cell::sync::OnceCell; +use rstest::*; +use std::{net::SocketAddr, thread}; +use tokio::{runtime::Runtime, sync::mpsc}; + +/// Context for some listening distant server +pub struct DistantServerCtx { + pub addr: SocketAddr, + pub key: String, + done_tx: mpsc::Sender<()>, +} + +impl DistantServerCtx { + pub fn initialize() -> Self { + let ip_addr = "127.0.0.1".parse().unwrap(); + let (done_tx, mut done_rx) = mpsc::channel(1); + let (started_tx, mut started_rx) = mpsc::channel(1); + + // NOTE: We spawn a dedicated thread that runs our tokio runtime separately from our test + // itself because using lua blocks the thread and prevents our runtime from working unless + // we make the tokio test multi-threaded using `tokio::test(flavor = "multi_thread", + // worker_threads = 1)` which isn't great because we're only using async tests for our + // server itself; so, we hide that away since our test logic doesn't need to be async + thread::spawn(move || match Runtime::new() { + Ok(rt) => { + rt.block_on(async move { + let opts = DistantServerOptions { + shutdown_after: None, + max_msg_capacity: 100, + }; + let key = SecretKey::default(); + let key_hex_string = key.unprotected_to_hex_key(); + let codec = XChaCha20Poly1305Codec::from(key); + let (_server, port) = + DistantServer::bind(ip_addr, "0".parse().unwrap(), codec, opts) + .await + .unwrap(); + + started_tx.send(Ok((port, key_hex_string))).await.unwrap(); + + let _ = done_rx.recv().await; + }); + } + Err(x) => { + started_tx.blocking_send(Err(x)).unwrap(); + } + }); + + // Extract our server startup data if we succeeded + let (port, key) = started_rx.blocking_recv().unwrap().unwrap(); + + Self { + addr: SocketAddr::new(ip_addr, port), + key, + done_tx, + } + } +} + +impl Drop for DistantServerCtx { + /// Kills server upon drop + fn drop(&mut self) { + let _ = self.done_tx.send(()); + } +} + +/// Returns a reference to the global distant server +#[fixture] +pub fn ctx() -> &'static DistantServerCtx { + static CTX: OnceCell = OnceCell::new(); + + CTX.get_or_init(DistantServerCtx::initialize) +} diff --git a/distant-lua-tests/tests/common/lua.rs b/distant-lua-tests/tests/common/lua.rs new file mode 100644 index 0000000..2b585dc --- /dev/null +++ b/distant-lua-tests/tests/common/lua.rs @@ -0,0 +1,41 @@ +use mlua::prelude::*; +use std::{env, path::PathBuf}; + +pub fn make() -> LuaResult { + let (dylib_path, dylib_ext, separator); + if cfg!(target_os = "macos") { + dylib_path = env::var("DYLD_FALLBACK_LIBRARY_PATH").unwrap(); + dylib_ext = "dylib"; + separator = ":"; + } else if cfg!(target_os = "linux") { + dylib_path = env::var("LD_LIBRARY_PATH").unwrap(); + dylib_ext = "so"; + separator = ":"; + } else if cfg!(target_os = "windows") { + dylib_path = env::var("PATH").unwrap(); + dylib_ext = "dll"; + separator = ";"; + } else { + panic!("unknown target os"); + }; + + let mut cpath = dylib_path + .split(separator) + .take(3) + .map(|p| { + let mut path = PathBuf::from(p); + path.push(format!("lib?.{}", dylib_ext)); + path.to_str().unwrap().to_owned() + }) + .collect::>() + .join(";"); + + if cfg!(target_os = "windows") { + cpath = cpath.replace("\\", "\\\\"); + cpath = cpath.replace("lib?.", "?."); + } + + let lua = unsafe { Lua::unsafe_new() }; // To be able to load C modules + lua.load(&format!("package.cpath = \"{}\"", cpath)).exec()?; + Ok(lua) +} diff --git a/distant-lua-tests/tests/common/mod.rs b/distant-lua-tests/tests/common/mod.rs new file mode 100644 index 0000000..b5d5544 --- /dev/null +++ b/distant-lua-tests/tests/common/mod.rs @@ -0,0 +1,4 @@ +pub mod fixtures; +pub mod lua; +pub mod poll; +pub mod session; diff --git a/distant-lua-tests/tests/common/poll.rs b/distant-lua-tests/tests/common/poll.rs new file mode 100644 index 0000000..c29b388 --- /dev/null +++ b/distant-lua-tests/tests/common/poll.rs @@ -0,0 +1,17 @@ +use mlua::{chunk, prelude::*}; +use std::{thread, time::Duration}; + +/// Creates a function that can be passed as the schedule function for `wrap_async` +pub fn make_function(lua: &Lua) -> LuaResult { + let sleep = lua.create_function(|_, ()| { + thread::sleep(Duration::from_millis(10)); + Ok(()) + })?; + + lua.load(chunk! { + local cb = ... + $sleep() + cb() + }) + .into_function() +} diff --git a/distant-lua-tests/tests/common/session.rs b/distant-lua-tests/tests/common/session.rs new file mode 100644 index 0000000..645c43c --- /dev/null +++ b/distant-lua-tests/tests/common/session.rs @@ -0,0 +1,41 @@ +use super::fixtures::DistantServerCtx; +use mlua::{chunk, prelude::*}; + +/// Creates a function that produces a session within the provided Lua environment +/// using the given distant server context, returning the session's id +pub fn make_function<'a>(lua: &'a Lua, ctx: &'_ DistantServerCtx) -> LuaResult> { + let addr = ctx.addr; + let host = addr.ip().to_string(); + let port = addr.port(); + let key = ctx.key.clone(); + + lua.load(chunk! { + local distant = require("distant_lua") + local thread = coroutine.create(distant.session.connect_async) + + local status, res = coroutine.resume(thread, { + host = $host, + port = $port, + key = $key, + timeout = 15000, + }) + + // Block until the connection finishes + local session = nil + while status do + if status and res ~= distant.PENDING then + session = res + break + end + + status, res = coroutine.resume(thread) + end + + if session then + return session + else + error(res) + end + }) + .into_function() +} diff --git a/distant-lua-tests/tests/lib.rs b/distant-lua-tests/tests/lib.rs new file mode 100644 index 0000000..650717b --- /dev/null +++ b/distant-lua-tests/tests/lib.rs @@ -0,0 +1,2 @@ +mod common; +mod lua; diff --git a/distant-lua-tests/tests/lua/async/append_file.rs b/distant-lua-tests/tests/lua/async/append_file.rs new file mode 100644 index 0000000..943ce00 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/append_file.rs @@ -0,0 +1,79 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.append_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $data }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_append_data_to_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.append_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $data }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("line 1some text"); +} diff --git a/distant-lua-tests/tests/lua/async/append_file_text.rs b/distant-lua-tests/tests/lua/async/append_file_text.rs new file mode 100644 index 0000000..12070ea --- /dev/null +++ b/distant-lua-tests/tests/lua/async/append_file_text.rs @@ -0,0 +1,79 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.append_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $text }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_append_data_to_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.append_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $text }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("line 1some text"); +} diff --git a/distant-lua-tests/tests/lua/async/copy.rs b/distant-lua-tests/tests/lua/async/copy.rs new file mode 100644 index 0000000..56fc2d0 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/copy.rs @@ -0,0 +1,210 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_send_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.copy_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_copying_an_entire_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.copy_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_copying_an_empty_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.copy_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Verify that we still have source and destination directories + src.assert(predicate::path::is_dir()); + dst.assert(predicate::path::is_dir()); +} + +#[rstest] +fn should_support_copying_a_directory_that_only_contains_directories(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.copy_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_copying_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.copy_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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())); +} diff --git a/distant-lua-tests/tests/lua/async/create_dir.rs b/distant-lua-tests/tests/lua/async/create_dir.rs new file mode 100644 index 0000000..d446371 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/create_dir.rs @@ -0,0 +1,129 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +// /root/ +// /root/file1 +// /root/link1 -> /root/sub1/file2 +// /root/sub1/ +// /root/sub1/file2 +fn setup_dir() -> assert_fs::TempDir { + let root_dir = assert_fs::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] +fn should_send_error_if_fails(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Make a path that has multiple non-existent components + // so the creation will fail + let root_dir = setup_dir(); + let path = root_dir.path().join("nested").join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.create_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $path_str }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was not actually created + assert!(!path.exists(), "Path unexpectedly exists"); +} + +#[rstest] +fn should_send_ok_when_successful(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let root_dir = setup_dir(); + let path = root_dir.path().join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.create_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $path_str }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} + +#[rstest] +fn should_support_creating_multiple_dir_components(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let root_dir = setup_dir(); + let path = root_dir.path().join("nested").join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.create_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $path_str, all = true }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} diff --git a/distant-lua-tests/tests/lua/async/exists.rs b/distant-lua-tests/tests/lua/async/exists.rs new file mode 100644 index 0000000..3254de3 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/exists.rs @@ -0,0 +1,73 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_send_true_if_path_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.touch().unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.exists_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, exists + f(session, { path = $file_path }, function(success, res) + if success then + exists = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(exists == true, "Invalid exists return value") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_send_false_if_path_does_not_exist(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.exists_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, exists + f(session, { path = $file_path }, function(success, res) + if success then + exists = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(exists == false, "Invalid exists return value") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/metadata.rs b/distant-lua-tests/tests/lua/async/metadata.rs new file mode 100644 index 0000000..735e6a0 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/metadata.rs @@ -0,0 +1,238 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_send_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_file_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, metadata + f(session, { path = $file_path }, function(success, res) + if success then + metadata = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(metadata, "Missing metadata") + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "file", "Got wrong file type: " .. metadata.file_type) + assert(metadata.len == 9, "Got wrong len: " .. metadata.len) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_dir_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, metadata + f(session, { path = $dir_path }, function(success, res) + if success then + metadata = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(metadata, "Missing metadata") + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "dir", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_symlink_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::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 symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, metadata + f(session, { path = $symlink_path }, function(success, res) + if success then + metadata = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(metadata, "Missing metadata") + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "symlink", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_include_canonicalized_path_if_flag_specified(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + let file_path = file.path().canonicalize().unwrap(); + let file_path_str = file_path.to_str().unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + let symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, metadata + f(session, { path = $symlink_path, canonicalize = true }, function(success, res) + if success then + metadata = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(metadata, "Missing metadata") + assert( + metadata.canonicalized_path == $file_path_str, + "Got wrong canonicalized path: " .. metadata.canonicalized_path + ) + assert(metadata.file_type == "symlink", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_resolve_file_type_of_symlink_if_flag_specified(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::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 symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.metadata_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, metadata + f(session, { path = $symlink_path, resolve_file_type = true }, function(success, res) + if success then + metadata = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(metadata, "Missing metadata") + assert(metadata.file_type == "file", "Got wrong file type: " .. metadata.file_type) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/mod.rs b/distant-lua-tests/tests/lua/async/mod.rs new file mode 100644 index 0000000..9761935 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/mod.rs @@ -0,0 +1,14 @@ +mod append_file; +mod append_file_text; +mod copy; +mod create_dir; +mod exists; +mod metadata; +mod read_dir; +mod read_file; +mod read_file_text; +mod remove; +mod rename; +mod spawn; +mod write_file; +mod write_file_text; diff --git a/distant-lua-tests/tests/lua/async/read_dir.rs b/distant-lua-tests/tests/lua/async/read_dir.rs new file mode 100644 index 0000000..afc4a46 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/read_dir.rs @@ -0,0 +1,357 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +// /root/ +// /root/file1 +// /root/link1 -> /root/sub1/file2 +// /root/sub1/ +// /root/sub1/file2 +fn setup_dir() -> assert_fs::TempDir { + let root_dir = assert_fs::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] +fn should_return_error_if_directory_does_not_exist(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("test-dir"); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $dir_path }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_have_depth_default_to_1(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_depth_limits(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path, depth = 1 }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_unlimited_depth_using_zero(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path, depth = 0 }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + + assert(entries[4].file_type == "file", "Wrong file type: " .. entries[4].file_type) + assert(entries[4].path == "sub1/file2", "Wrong path: " .. entries[4].path) + assert(entries[4].depth == 2, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_including_directory_in_returned_entries(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + let root_dir_canonicalized_path = root_dir.path().canonicalize().unwrap(); + let root_dir_canonicalized_path_str = root_dir_canonicalized_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path, include_root = true }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "dir", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == $root_dir_canonicalized_path_str, "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 0, "Wrong depth") + + assert(entries[2].file_type == "file", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "file1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "symlink", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "link1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + + assert(entries[4].file_type == "dir", "Wrong file type: " .. entries[4].file_type) + assert(entries[4].path == "sub1", "Wrong path: " .. entries[4].path) + assert(entries[4].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_returning_absolute_paths(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let root_dir_canonicalized_path = root_dir.path().canonicalize().unwrap(); + let file1_path = root_dir_canonicalized_path.join("file1"); + let link1_path = root_dir_canonicalized_path.join("link1"); + let sub1_path = root_dir_canonicalized_path.join("sub1"); + + let file1_path_str = file1_path.to_str().unwrap(); + let link1_path_str = link1_path.to_str().unwrap(); + let sub1_path_str = sub1_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path, absolute = true }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == $file1_path_str, "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == $link1_path_str, "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == $sub1_path_str, "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_returning_canonicalized_paths(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_dir_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, tbl + f(session, { path = $root_dir_path, canonicalize = true }, function(success, res) + if success then + tbl = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(tbl, "Missing result") + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "sub1/file2", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/read_file.rs b/distant-lua-tests/tests/lua/async/read_file.rs new file mode 100644 index 0000000..27029f4 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/read_file.rs @@ -0,0 +1,79 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_return_error_if_fails_to_read_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path_str }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_file_contents_as_byte_list(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("abcd").unwrap(); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, contents + f(session, { path = $file_path_str }, function(success, res) + if success then + contents = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(contents, "Missing file contents") + + // abcd -> {97, 98, 99, 100} + assert(type(contents) == "table", "Wrong content type: " .. type(contents)) + assert(contents[1] == 97, "Unexpected first byte: " .. contents[1]) + assert(contents[2] == 98, "Unexpected second byte: " .. contents[2]) + assert(contents[3] == 99, "Unexpected third byte: " .. contents[3]) + assert(contents[4] == 100, "Unexpected fourth byte: " .. contents[4]) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/read_file_text.rs b/distant-lua-tests/tests/lua/async/read_file_text.rs new file mode 100644 index 0000000..c5c29e6 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/read_file_text.rs @@ -0,0 +1,74 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_return_error_if_fails_to_read_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.read_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path_str }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_file_contents_as_byte_list(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local contents = session:read_file({ path = $file_path_str }) + local f = require("distant_lua").utils.wrap_async( + session.read_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err, contents + f(session, { path = $file_path_str }, function(success, res) + if success then + contents = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed") + assert(contents, "Missing file contents") + assert(contents == "some file contents", "Unexpected file contents: " .. contents) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/remove.rs b/distant-lua-tests/tests/lua/async/remove.rs new file mode 100644 index 0000000..67616a5 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/remove.rs @@ -0,0 +1,145 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.remove_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_deleting_a_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.remove_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $dir_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +fn should_delete_nonempty_directory_if_force_is_true(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + dir.child("file").touch().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.remove_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $dir_path, force = true }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_deleting_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("some-file"); + file.touch().unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.remove_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} diff --git a/distant-lua-tests/tests/lua/async/rename.rs b/distant-lua-tests/tests/lua/async/rename.rs new file mode 100644 index 0000000..716661b --- /dev/null +++ b/distant-lua-tests/tests/lua/async/rename.rs @@ -0,0 +1,126 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.rename_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_renaming_an_entire_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.rename_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_renaming_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.rename_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { src = $src_path, dst = $dst_path }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Verify that we moved the file + src.assert(predicate::path::missing()); + dst.assert("some text"); +} diff --git a/distant-lua-tests/tests/lua/async/spawn.rs b/distant-lua-tests/tests/lua/async/spawn.rs new file mode 100644 index 0000000..3933d47 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/spawn.rs @@ -0,0 +1,437 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use once_cell::sync::Lazy; +use rstest::*; + +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| assert_fs::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] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(); + let args: Vec = Vec::new(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { cmd = $cmd, args = $args }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_back_process_on_success(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc.id >= 0, "Invalid process returned") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string(), + String::from("some stdout"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc, "Missing proc") + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local f = distant.utils.wrap_async(proc.read_stdout_async, $schedule_fn) + local err, stdout + f(proc, function(success, res) + if success then + stdout = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout") + assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stderr(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDERR_SH.to_str().unwrap().to_string(), + String::from("some stderr"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc, "Missing proc") + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local f = distant.utils.wrap_async(proc.read_stderr_async, $schedule_fn) + local err, stderr + f(proc, function(success, res) + if success then + stderr = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout") + assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_when_killing_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc, "Missing proc") + + // Wait briefly to ensure the process dies + $wait_fn() + + local f = distant.utils.wrap_async(proc.kill_async, $schedule_fn) + local err + f(proc, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded in killing dead process") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_killing_processing(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")]; + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc, "Missing proc") + + local f = distant.utils.wrap_async(proc.kill_async, $schedule_fn) + local err + f(proc, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed to kill process") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_if_sending_stdin_to_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed to spawn process") + assert(proc, "Missing proc") + + // Wait briefly to ensure the process dies + $wait_fn() + + local f = distant.utils.wrap_async(proc.write_stdin_async, $schedule_fn) + local err + f(proc, "some text\n", function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let result = lua + .load(chunk! { + local session = $new_session() + local distant = require("distant_lua") + local f = distant.utils.wrap_async(session.spawn_async, $schedule_fn) + + // Because of our scheduler, the invocation turns async -> sync + local err, proc + f(session, { cmd = $cmd, args = $args }, function(success, res) + if success then + proc = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed spawning process") + assert(proc, "Missing proc") + + local f = distant.utils.wrap_async(proc.write_stdin_async, $schedule_fn) + local err + f(proc, "some text\n", function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed writing stdin") + + local f = distant.utils.wrap_async(proc.read_stdout_async, $schedule_fn) + local err, stdout + f(proc, function(success, res) + if success then + stdout = res + else + err = res + end + end) + assert(not err, "Unexpectedly failed reading stdout") + assert(stdout == "some text\n", "Unexpected stdout received: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/async/write_file.rs b/distant-lua-tests/tests/lua/async/write_file.rs new file mode 100644 index 0000000..0ab6414 --- /dev/null +++ b/distant-lua-tests/tests/lua/async/write_file.rs @@ -0,0 +1,79 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.write_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $data }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_overwrite_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.write_file_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $data }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we overwrite the file + file.assert("some text"); +} diff --git a/distant-lua-tests/tests/lua/async/write_file_text.rs b/distant-lua-tests/tests/lua/async/write_file_text.rs new file mode 100644 index 0000000..2490bce --- /dev/null +++ b/distant-lua-tests/tests/lua/async/write_file_text.rs @@ -0,0 +1,79 @@ +use crate::common::{fixtures::*, lua, poll, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.write_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $text }, function(success, res) + if not success then + err = res + end + end) + assert(err, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_overwrite_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + let schedule_fn = poll::make_function(&lua).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local f = require("distant_lua").utils.wrap_async( + session.write_file_text_async, + $schedule_fn + ) + + // Because of our scheduler, the invocation turns async -> sync + local err + f(session, { path = $file_path, data = $text }, function(success, res) + if not success then + err = res + end + end) + assert(not err, "Unexpectedly failed") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("some text"); +} diff --git a/distant-lua-tests/tests/lua/mod.rs b/distant-lua-tests/tests/lua/mod.rs new file mode 100644 index 0000000..7b89c4c --- /dev/null +++ b/distant-lua-tests/tests/lua/mod.rs @@ -0,0 +1,2 @@ +mod r#async; +mod sync; diff --git a/distant-lua-tests/tests/lua/sync/append_file.rs b/distant-lua-tests/tests/lua/sync/append_file.rs new file mode 100644 index 0000000..e9bf264 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/append_file.rs @@ -0,0 +1,60 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.append_file, session, { + path = $file_path, + data = $data + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_append_data_to_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:append_file({ + path = $file_path, + data = $data + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("line 1some text"); +} diff --git a/distant-lua-tests/tests/lua/sync/append_file_text.rs b/distant-lua-tests/tests/lua/sync/append_file_text.rs new file mode 100644 index 0000000..abbc134 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/append_file_text.rs @@ -0,0 +1,60 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.append_file_text, session, { + path = $file_path, + data = $text + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_append_data_to_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:append_file_text({ + path = $file_path, + data = $text + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("line 1some text"); +} diff --git a/distant-lua-tests/tests/lua/sync/copy.rs b/distant-lua-tests/tests/lua/sync/copy.rs new file mode 100644 index 0000000..3adbdaa --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/copy.rs @@ -0,0 +1,161 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_send_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.copy, session, { + src = $src_path, + dst = $dst_path + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_copying_an_entire_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:copy({ + src = $src_path, + dst = $dst_path + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_copying_an_empty_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.create_dir_all().unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:copy({ + src = $src_path, + dst = $dst_path + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Verify that we still have source and destination directories + src.assert(predicate::path::is_dir()); + dst.assert(predicate::path::is_dir()); +} + +#[rstest] +fn should_support_copying_a_directory_that_only_contains_directories(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:copy({ + src = $src_path, + dst = $dst_path + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_copying_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:copy({ + src = $src_path, + dst = $dst_path + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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())); +} diff --git a/distant-lua-tests/tests/lua/sync/create_dir.rs b/distant-lua-tests/tests/lua/sync/create_dir.rs new file mode 100644 index 0000000..53dba47 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/create_dir.rs @@ -0,0 +1,91 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +// /root/ +// /root/file1 +// /root/link1 -> /root/sub1/file2 +// /root/sub1/ +// /root/sub1/file2 +fn setup_dir() -> assert_fs::TempDir { + let root_dir = assert_fs::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] +fn should_send_error_if_fails(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Make a path that has multiple non-existent components + // so the creation will fail + let root_dir = setup_dir(); + let path = root_dir.path().join("nested").join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.create_dir, session, { path = $path_str }) + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was not actually created + assert!(!path.exists(), "Path unexpectedly exists"); +} + +#[rstest] +fn should_send_ok_when_successful(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let root_dir = setup_dir(); + let path = root_dir.path().join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:create_dir({ path = $path_str }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} + +#[rstest] +fn should_support_creating_multiple_dir_components(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let root_dir = setup_dir(); + let path = root_dir.path().join("nested").join("new-dir"); + let path_str = path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:create_dir({ path = $path_str, all = true }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that the directory was actually created + assert!(path.exists(), "Directory not created"); +} diff --git a/distant-lua-tests/tests/lua/sync/exists.rs b/distant-lua-tests/tests/lua/sync/exists.rs new file mode 100644 index 0000000..f5c3eae --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/exists.rs @@ -0,0 +1,43 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_send_true_if_path_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.touch().unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local exists = session:exists({ path = $file_path }) + assert(exists, "File unexpectedly missing") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_send_false_if_path_does_not_exist(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local exists = session:exists({ path = $file_path }) + assert(not exists, "File unexpectedly found") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/metadata.rs b/distant-lua-tests/tests/lua/sync/metadata.rs new file mode 100644 index 0000000..0e2a56b --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/metadata.rs @@ -0,0 +1,152 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_send_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.metadata, session, { path = $file_path }) + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_file_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local metadata = session:metadata({ path = $file_path }) + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "file", "Got wrong file type: " .. metadata.file_type) + assert(metadata.len == 9, "Got wrong len: " .. metadata.len) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_dir_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local metadata = session:metadata({ path = $dir_path }) + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "dir", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_metadata_on_symlink_if_exists(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::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 symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local metadata = session:metadata({ path = $symlink_path }) + assert(not metadata.canonicalized_path, "Unexpectedly got canonicalized path") + assert(metadata.file_type == "symlink", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_include_canonicalized_path_if_flag_specified(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("file"); + file.write_str("some text").unwrap(); + let file_path = file.path().canonicalize().unwrap(); + let file_path_str = file_path.to_str().unwrap(); + + let symlink = temp.child("link"); + symlink.symlink_to_file(file.path()).unwrap(); + let symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local metadata = session:metadata({ + path = $symlink_path, + canonicalize = true, + }) + assert( + metadata.canonicalized_path == $file_path_str, + "Got wrong canonicalized path: " .. metadata.canonicalized_path + ) + assert(metadata.file_type == "symlink", "Got wrong file type: " .. metadata.file_type) + assert(not metadata.readonly, "Unexpectedly readonly") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_resolve_file_type_of_symlink_if_flag_specified(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::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 symlink_path = symlink.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local metadata = session:metadata({ + path = $symlink_path, + resolve_file_type = true, + }) + assert(metadata.file_type == "file", "Got wrong file type: " .. metadata.file_type) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/mod.rs b/distant-lua-tests/tests/lua/sync/mod.rs new file mode 100644 index 0000000..9761935 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/mod.rs @@ -0,0 +1,14 @@ +mod append_file; +mod append_file_text; +mod copy; +mod create_dir; +mod exists; +mod metadata; +mod read_dir; +mod read_file; +mod read_file_text; +mod remove; +mod rename; +mod spawn; +mod write_file; +mod write_file_text; diff --git a/distant-lua-tests/tests/lua/sync/read_dir.rs b/distant-lua-tests/tests/lua/sync/read_dir.rs new file mode 100644 index 0000000..20c7ab3 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/read_dir.rs @@ -0,0 +1,249 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +// /root/ +// /root/file1 +// /root/link1 -> /root/sub1/file2 +// /root/sub1/ +// /root/sub1/file2 +fn setup_dir() -> assert_fs::TempDir { + let root_dir = assert_fs::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] +fn should_return_error_if_directory_does_not_exist(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("test-dir"); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.read_dir, session, { path = $dir_path }) + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_have_depth_default_to_1(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path }) + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_depth_limits(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path, depth = 1 }) + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_unlimited_depth_using_zero(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path, depth = 0 }) + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "link1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + + assert(entries[4].file_type == "file", "Wrong file type: " .. entries[4].file_type) + assert(entries[4].path == "sub1/file2", "Wrong path: " .. entries[4].path) + assert(entries[4].depth == 2, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_including_directory_in_returned_entries(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + let root_dir_canonicalized_path = root_dir.path().canonicalize().unwrap(); + let root_dir_canonicalized_path_str = root_dir_canonicalized_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path, include_root = true }) + + local entries = tbl.entries + assert(entries[1].file_type == "dir", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == $root_dir_canonicalized_path_str, "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 0, "Wrong depth") + + assert(entries[2].file_type == "file", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "file1", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "symlink", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "link1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + + assert(entries[4].file_type == "dir", "Wrong file type: " .. entries[4].file_type) + assert(entries[4].path == "sub1", "Wrong path: " .. entries[4].path) + assert(entries[4].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_returning_absolute_paths(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let root_dir_canonicalized_path = root_dir.path().canonicalize().unwrap(); + let file1_path = root_dir_canonicalized_path.join("file1"); + let link1_path = root_dir_canonicalized_path.join("link1"); + let sub1_path = root_dir_canonicalized_path.join("sub1"); + + let file1_path_str = file1_path.to_str().unwrap(); + let link1_path_str = link1_path.to_str().unwrap(); + let sub1_path_str = sub1_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path, absolute = true }) + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == $file1_path_str, "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == $link1_path_str, "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == $sub1_path_str, "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_returning_canonicalized_paths(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Create directory with some nested items + let root_dir = setup_dir(); + let root_dir_path = root_dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local tbl = session:read_dir({ path = $root_dir_path, canonicalize = true }) + + local entries = tbl.entries + assert(entries[1].file_type == "file", "Wrong file type: " .. entries[1].file_type) + assert(entries[1].path == "file1", "Wrong path: " .. entries[1].path) + assert(entries[1].depth == 1, "Wrong depth") + + assert(entries[2].file_type == "symlink", "Wrong file type: " .. entries[2].file_type) + assert(entries[2].path == "sub1/file2", "Wrong path: " .. entries[2].path) + assert(entries[2].depth == 1, "Wrong depth") + + assert(entries[3].file_type == "dir", "Wrong file type: " .. entries[3].file_type) + assert(entries[3].path == "sub1", "Wrong path: " .. entries[3].path) + assert(entries[3].depth == 1, "Wrong depth") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/read_file.rs b/distant-lua-tests/tests/lua/sync/read_file.rs new file mode 100644 index 0000000..1dadf9e --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/read_file.rs @@ -0,0 +1,51 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_return_error_if_fails_to_read_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.read_file, session, { path = $file_path_str }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_file_contents_as_byte_list(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("abcd").unwrap(); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local contents = session:read_file({ path = $file_path_str }) + + // abcd -> {97, 98, 99, 100} + assert(type(contents) == "table", "Wrong content type: " .. type(contents)) + assert(contents[1] == 97, "Unexpected first byte: " .. contents[1]) + assert(contents[2] == 98, "Unexpected second byte: " .. contents[2]) + assert(contents[3] == 99, "Unexpected third byte: " .. contents[3]) + assert(contents[4] == 100, "Unexpected fourth byte: " .. contents[4]) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/read_file_text.rs b/distant-lua-tests/tests/lua/sync/read_file_text.rs new file mode 100644 index 0000000..cf0ad17 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/read_file_text.rs @@ -0,0 +1,45 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use rstest::*; + +#[rstest] +fn should_return_error_if_fails_to_read_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.read_file_text, session, { path = $file_path_str }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_file_contents_as_text(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("some file contents").unwrap(); + let file_path = file.path(); + let file_path_str = file_path.to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local contents = session:read_file_text({ path = $file_path_str }) + assert(contents == "some file contents", "Unexpected file contents: " .. contents) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/remove.rs b/distant-lua-tests/tests/lua/sync/remove.rs new file mode 100644 index 0000000..505b2cf --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/remove.rs @@ -0,0 +1,94 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("missing-file"); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.remove, session, { path = $file_path }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_deleting_a_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:remove({ path = $dir_path }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +fn should_delete_nonempty_directory_if_force_is_true(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let dir = temp.child("dir"); + dir.create_dir_all().unwrap(); + dir.child("file").touch().unwrap(); + let dir_path = dir.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:remove({ path = $dir_path, force = true }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + dir.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_deleting_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("some-file"); + file.touch().unwrap(); + let file_path = file.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:remove({ path = $file_path }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that path does not exist + file.assert(predicate::path::missing()); +} diff --git a/distant-lua-tests/tests/lua/sync/rename.rs b/distant-lua-tests/tests/lua/sync/rename.rs new file mode 100644 index 0000000..4b450f5 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/rename.rs @@ -0,0 +1,97 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + let dst = temp.child("dst"); + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.rename, session, { + src = $src_path, + dst = $dst_path, + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also, verify that destination does not exist + dst.assert(predicate::path::missing()); +} + +#[rstest] +fn should_support_renaming_an_entire_directory(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::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 src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:rename({ + src = $src_path, + dst = $dst_path, + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // 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] +fn should_support_renaming_a_single_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let src = temp.child("src"); + src.write_str("some text").unwrap(); + let dst = temp.child("dst"); + + let src_path = src.path().to_str().unwrap(); + let dst_path = dst.path().to_str().unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:rename({ + src = $src_path, + dst = $dst_path, + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Verify that we moved the file + src.assert(predicate::path::missing()); + dst.assert("some text"); +} diff --git a/distant-lua-tests/tests/lua/sync/spawn.rs b/distant-lua-tests/tests/lua/sync/spawn.rs new file mode 100644 index 0000000..3d8bc2d --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/spawn.rs @@ -0,0 +1,288 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use once_cell::sync::Lazy; +use rstest::*; + +static TEMP_SCRIPT_DIR: Lazy = Lazy::new(|| assert_fs::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] +fn should_return_error_on_failure(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = DOES_NOT_EXIST_BIN.to_str().unwrap().to_string(); + let args: Vec = Vec::new(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.spawn, session, { + cmd = $cmd, + args = $args + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_back_process_on_success(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + assert(proc.id >= 0, "Invalid process returned") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stdout(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDOUT_SH.to_str().unwrap().to_string(), + String::from("some stdout"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local stdout = proc:read_stdout() + assert(stdout == "some stdout", "Unexpected stdout: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_return_process_that_can_retrieve_stderr(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ + ECHO_ARGS_TO_STDERR_SH.to_str().unwrap().to_string(), + String::from("some stderr"), + ]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + + // Wait briefly to ensure the process sends stdout + $wait_fn() + + local stderr = proc:read_stderr() + assert(stderr == "some stderr", "Unexpected stderr: " .. stderr) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_when_killing_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + + // Wait briefly to ensure the process dies + $wait_fn() + + local status, _ = pcall(proc.kill, proc) + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_support_killing_processing(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("1")]; + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + proc:kill() + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +#[rstest] +fn should_return_error_if_sending_stdin_to_dead_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // Spawn a process that will exit immediately, but is a valid process + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![SLEEP_SH.to_str().unwrap().to_string(), String::from("0")]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + + // Wait briefly to ensure the process dies + $wait_fn() + + local status, _ = pcall(proc.write_stdin, proc, "some text") + assert(not status, "Unexpectedly succeeded") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} + +// 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] +#[cfg_attr(windows, ignore)] +fn should_support_sending_stdin_to_spawned_process(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let cmd = SCRIPT_RUNNER.to_string(); + let args = vec![ECHO_STDIN_TO_STDOUT_SH.to_str().unwrap().to_string()]; + + let wait_fn = lua + .create_function(|_, ()| { + std::thread::sleep(std::time::Duration::from_millis(50)); + Ok(()) + }) + .unwrap(); + + let result = lua + .load(chunk! { + local session = $new_session() + local proc = session:spawn({ cmd = $cmd, args = $args }) + proc:write_stdin("some text\n") + + // Wait briefly to ensure the process echoes stdin + $wait_fn() + + local stdout = proc:read_stdout() + assert(stdout == "some text\n", "Unexpected stdin sent: " .. stdout) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); +} diff --git a/distant-lua-tests/tests/lua/sync/write_file.rs b/distant-lua-tests/tests/lua/sync/write_file.rs new file mode 100644 index 0000000..d2bcb6a --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/write_file.rs @@ -0,0 +1,60 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.write_file, session, { + path = $file_path, + data = $data + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_overwrite_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let data = b"some text".to_vec(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:write_file({ + path = $file_path, + data = $data + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we overwrite the file + file.assert("some text"); +} diff --git a/distant-lua-tests/tests/lua/sync/write_file_text.rs b/distant-lua-tests/tests/lua/sync/write_file_text.rs new file mode 100644 index 0000000..3d150b5 --- /dev/null +++ b/distant-lua-tests/tests/lua/sync/write_file_text.rs @@ -0,0 +1,60 @@ +use crate::common::{fixtures::*, lua, session}; +use assert_fs::prelude::*; +use mlua::chunk; +use predicates::prelude::*; +use rstest::*; + +#[rstest] +fn should_yield_error_if_fails_to_create_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + // 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 = assert_fs::TempDir::new().unwrap(); + let file = temp.child("dir").child("test-file"); + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + local status, _ = pcall(session.write_file_text, session, { + path = $file_path, + data = $text + }) + assert(not status, "Unexpectedly succeeded!") + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we didn't actually create the file + file.assert(predicate::path::missing()); +} + +#[rstest] +fn should_overwrite_existing_file(ctx: &'_ DistantServerCtx) { + let lua = lua::make().unwrap(); + let new_session = session::make_function(&lua, ctx).unwrap(); + + let temp = assert_fs::TempDir::new().unwrap(); + let file = temp.child("test-file"); + file.write_str("line 1").unwrap(); + + let file_path = file.path().to_str().unwrap(); + let text = "some text".to_string(); + + let result = lua + .load(chunk! { + local session = $new_session() + session:write_file_text({ + path = $file_path, + data = $text + }) + }) + .exec(); + assert!(result.is_ok(), "Failed: {}", result.unwrap_err()); + + // Also verify that we appended to the file + file.assert("some text"); +} diff --git a/distant-lua/.cargo/config.toml b/distant-lua/.cargo/config.toml new file mode 100644 index 0000000..d47f983 --- /dev/null +++ b/distant-lua/.cargo/config.toml @@ -0,0 +1,11 @@ +[target.x86_64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] + +[target.aarch64-apple-darwin] +rustflags = [ + "-C", "link-arg=-undefined", + "-C", "link-arg=dynamic_lookup", +] diff --git a/distant-lua/Cargo.toml b/distant-lua/Cargo.toml new file mode 100644 index 0000000..1607c53 --- /dev/null +++ b/distant-lua/Cargo.toml @@ -0,0 +1,41 @@ +[package] +name = "distant-lua" +description = "Lua bindings to the distant Rust crates" +categories = ["api-bindings", "network-programming"] +keywords = ["api", "async"] +version = "0.15.0-alpha.6" +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" + +[lib] +crate-type = ["cdylib"] + +[features] +default = ["lua51", "vendored"] +lua54 = ["mlua/lua54"] +lua53 = ["mlua/lua53"] +lua52 = ["mlua/lua52"] +lua51 = ["mlua/lua51"] +luajit = ["mlua/luajit"] +vendored = ["mlua/vendored"] + +[dependencies] +distant-core = { version = "=0.15.0-alpha.6", path = "../distant-core" } +distant-ssh2 = { version = "=0.15.0-alpha.6", features = ["serde"], path = "../distant-ssh2" } +futures = "0.3.17" +log = "0.4.14" +mlua = { version = "0.6.5", features = ["async", "macros", "module", "serialize"] } +once_cell = "1.8.0" +oorandom = "11.1.3" +paste = "1.0.5" +serde = { version = "1.0.130", features = ["derive"] } +simplelog = "0.10.2" +tokio = { version = "1.12.0", features = ["macros", "time"] } +whoami = "1.1.4" + +[dev-dependencies] +rstest = "0.11.0" diff --git a/distant-lua/README.md b/distant-lua/README.md new file mode 100644 index 0000000..2554295 --- /dev/null +++ b/distant-lua/README.md @@ -0,0 +1,65 @@ +# Distant Lua (module) + +Contains the Lua module wrapper around several distant libraries +including: + +1. **distant-core** +2. **distant-ssh2** + +## Building + +*Compilation MUST be done within this directory! This crate depends on +.cargo/config.toml settings, which are only used when built from within this +directory.* + +```bash +# Outputs a library file (*.so for Linux, *.dylib for MacOS) +cargo build --release +``` + +## Examples + +Rename `libdistant_lua.so` or `libdistant_lua.dylib` to `distant_lua.so` +(yes, **.so** for **.dylib**) and place the library in your Lua path. + +```lua +local distant = require("distant_lua") + +-- Distant functions are async by design and need to be wrapped in a coroutine +-- in order to be used +local thread = coroutine.wrap(distant.launch) + +-- Initialize the thread +thread({ host = "127.0.0.1" }) + +-- Continually check if launch has completed +local res +while true do + res = thread() + if res ~= distant.PENDING then + break + end +end +``` + +## Tests + +Tests are run in a separate crate due to linking described here: +[khvzak/mlua#79](https://github.com/khvzak/mlua/issues/79). You **must** build +this module prior to running the tests! + +```bash +# From root of repository +(cd distant-lua-tests && cargo test --release) +``` + +## License + +This project is licensed under either of + +Apache License, Version 2.0, (LICENSE-APACHE or +[apache-license][apache-license]) MIT license (LICENSE-MIT or +[mit-license][mit-license]) at your option. + +[apache-license]: http://www.apache.org/licenses/LICENSE-2.0 +[mit-license]: http://opensource.org/licenses/MIT diff --git a/distant-lua/src/lib.rs b/distant-lua/src/lib.rs new file mode 100644 index 0000000..cadd443 --- /dev/null +++ b/distant-lua/src/lib.rs @@ -0,0 +1,31 @@ +use mlua::prelude::*; + +/// to_value!<'a, T: Serialize + ?Sized>(lua: &'a Lua, t: &T) -> Result> +/// +/// Converts to a Lua value using options specific to this module. +macro_rules! to_value { + ($lua:expr, $x:expr) => {{ + use mlua::{prelude::*, LuaSerdeExt}; + let options = LuaSerializeOptions::new() + .serialize_none_to_null(false) + .serialize_unit_to_null(false); + $lua.to_value_with($x, options) + }}; +} + +mod log; +mod runtime; +mod session; +mod utils; + +#[mlua::lua_module] +fn distant_lua(lua: &Lua) -> LuaResult { + let exports = lua.create_table()?; + + exports.set("PENDING", utils::pending(lua)?)?; + exports.set("log", log::make_log_tbl(lua)?)?; + exports.set("session", session::make_session_tbl(lua)?)?; + exports.set("utils", utils::make_utils_tbl(lua)?)?; + + Ok(exports) +} diff --git a/distant-lua/src/log.rs b/distant-lua/src/log.rs new file mode 100644 index 0000000..9481df5 --- /dev/null +++ b/distant-lua/src/log.rs @@ -0,0 +1,114 @@ +use mlua::prelude::*; +use serde::{Deserialize, Serialize}; +use simplelog::{ + ColorChoice, CombinedLogger, ConfigBuilder, LevelFilter, SharedLogger, TermLogger, + TerminalMode, WriteLogger, +}; +use std::{fs::File, path::PathBuf}; + +macro_rules! set_log_fn { + ($lua:expr, $tbl:expr, $name:ident) => { + $tbl.set( + stringify!($name), + $lua.create_function(|_, msg: String| { + ::log::$name!("{}", msg); + Ok(()) + })?, + )?; + }; +} + +#[derive(Copy, Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "snake_case")] +enum LogLevel { + Off, + Error, + Warn, + Info, + Debug, + Trace, +} + +impl From for LevelFilter { + fn from(level: LogLevel) -> Self { + match level { + LogLevel::Off => Self::Off, + LogLevel::Error => Self::Error, + LogLevel::Warn => Self::Warn, + LogLevel::Info => Self::Info, + LogLevel::Debug => Self::Debug, + LogLevel::Trace => Self::Trace, + } + } +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(default)] +struct LogOpts { + /// Indicating whether or not to log to terminal + terminal: bool, + + /// Path to file to store logs + file: Option, + + /// Base level at which to write logs + /// (e.g. if debug then trace would not be logged) + level: LogLevel, +} + +impl Default for LogOpts { + fn default() -> Self { + Self { + terminal: false, + file: None, + level: LogLevel::Warn, + } + } +} + +fn init_logger(opts: LogOpts) -> LuaResult<()> { + let mut loggers: Vec> = Vec::new(); + let config = ConfigBuilder::new() + .add_filter_allow_str("distant_core") + .add_filter_allow_str("distant_ssh2") + .add_filter_allow_str("distant_lua") + .build(); + + if opts.terminal { + loggers.push(TermLogger::new( + opts.level.into(), + config.clone(), + TerminalMode::Mixed, + ColorChoice::Auto, + )); + } + + if let Some(path) = opts.file { + loggers.push(WriteLogger::new( + opts.level.into(), + config, + File::create(path)?, + )); + } + + CombinedLogger::init(loggers).to_lua_err()?; + Ok(()) +} + +/// Makes a Lua table containing the log functions +pub fn make_log_tbl(lua: &Lua) -> LuaResult { + let tbl = lua.create_table()?; + + tbl.set( + "init", + lua.create_function(|lua, opts: LuaValue| init_logger(lua.from_value(opts)?))?, + )?; + + set_log_fn!(lua, tbl, error); + set_log_fn!(lua, tbl, warn); + set_log_fn!(lua, tbl, info); + set_log_fn!(lua, tbl, debug); + set_log_fn!(lua, tbl, trace); + + Ok(tbl) +} diff --git a/distant-lua/src/runtime.rs b/distant-lua/src/runtime.rs new file mode 100644 index 0000000..b26402c --- /dev/null +++ b/distant-lua/src/runtime.rs @@ -0,0 +1,38 @@ +use futures::{FutureExt, TryFutureExt}; +use mlua::prelude::*; +use once_cell::sync::OnceCell; +use std::future::Future; + +/// Retrieves the global runtime, initializing it if not initialized, and returning +/// an error if failed to initialize +pub fn get_runtime() -> LuaResult<&'static tokio::runtime::Runtime> { + static RUNTIME: OnceCell = OnceCell::new(); + RUNTIME.get_or_try_init(|| { + tokio::runtime::Builder::new_multi_thread() + .enable_all() + .build() + .map_err(|x| x.to_lua_err()) + }) +} + +/// Blocks using the global runtime for a future that returns `LuaResult` +pub fn block_on(future: F) -> LuaResult +where + F: Future>, +{ + get_runtime()?.block_on(future) +} + +/// Spawns a task on the global runtime for a future that returns a `LuaResult` +pub fn spawn(f: F) -> impl Future> +where + F: Future> + Send + 'static, + T: Send + 'static, +{ + futures::future::ready(get_runtime()).and_then(|rt| { + rt.spawn(f).map(|result| match result { + Ok(x) => x.to_lua_err(), + Err(x) => Err(x).to_lua_err(), + }) + }) +} diff --git a/distant-lua/src/session.rs b/distant-lua/src/session.rs new file mode 100644 index 0000000..ac5ad79 --- /dev/null +++ b/distant-lua/src/session.rs @@ -0,0 +1,280 @@ +use crate::{runtime, utils}; +use distant_core::{ + SecretKey32, Session as DistantSession, SessionChannel, XChaCha20Poly1305Codec, +}; +use distant_ssh2::{IntoDistantSessionOpts, Ssh2Session}; +use mlua::{prelude::*, LuaSerdeExt, UserData, UserDataFields, UserDataMethods}; +use once_cell::sync::Lazy; +use paste::paste; +use std::{collections::HashMap, io, sync::RwLock}; + +/// Makes a Lua table containing the session functions +pub fn make_session_tbl(lua: &Lua) -> LuaResult { + let tbl = lua.create_table()?; + + // get_by_id(id: usize) -> Option + tbl.set( + "get_by_id", + lua.create_function(|_, id: usize| { + let exists = has_session(id)?; + if exists { + Ok(Some(Session::new(id))) + } else { + Ok(None) + } + })?, + )?; + + // launch(opts: LaunchOpts) -> Session + tbl.set( + "launch", + lua.create_function(|lua, opts: LuaValue| { + let opts = LaunchOpts::from_lua(opts, lua)?; + runtime::block_on(Session::launch(opts)) + })?, + )?; + + // connect_async(opts: ConnectOpts) -> Future + tbl.set( + "connect_async", + lua.create_async_function(|lua, opts: LuaValue| async move { + let opts = ConnectOpts::from_lua(opts, lua)?; + runtime::spawn(Session::connect(opts)).await + })?, + )?; + + // connect(opts: ConnectOpts) -> Session + tbl.set( + "connect", + lua.create_function(|lua, opts: LuaValue| { + let opts = ConnectOpts::from_lua(opts, lua)?; + runtime::block_on(Session::connect(opts)) + })?, + )?; + + Ok(tbl) +} + +/// try_timeout!(timeout: Duration, Future>) -> LuaResult +macro_rules! try_timeout { + ($timeout:expr, $f:expr) => {{ + use futures::future::FutureExt; + use mlua::prelude::*; + let timeout: std::time::Duration = $timeout; + crate::runtime::spawn(async move { + let fut = ($f).fuse(); + let sleep = tokio::time::sleep(timeout).fuse(); + + tokio::select! { + _ = sleep => { + let err = std::io::Error::new( + std::io::ErrorKind::TimedOut, + format!("Reached timeout of {}s", timeout.as_secs_f32()) + ); + Err(err.to_lua_err()) + } + res = fut => { + res.to_lua_err() + } + } + }) + .await + }}; +} + +mod api; +mod opts; +mod proc; + +use opts::Mode; +pub use opts::{ConnectOpts, LaunchOpts}; +use proc::{RemoteLspProcess, RemoteProcess}; + +/// Contains mapping of id -> session for use in maintaining active sessions +static SESSION_MAP: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +fn has_session(id: usize) -> LuaResult { + Ok(SESSION_MAP + .read() + .map_err(|x| x.to_string().to_lua_err())? + .contains_key(&id)) +} + +fn get_session_channel(id: usize) -> LuaResult { + let lock = SESSION_MAP.read().map_err(|x| x.to_string().to_lua_err())?; + let session = lock.get(&id).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotConnected, + format!("No session connected with id {}", id), + ) + .to_lua_err() + })?; + + Ok(session.clone_channel()) +} + +/// Holds a reference to the session to perform remote operations +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct Session { + id: usize, +} + +impl Session { + /// Creates a new session referencing the given distant session with the specified id + pub fn new(id: usize) -> Self { + Self { id } + } + + /// Launches a new distant session on a remote machine + pub async fn launch(opts: LaunchOpts<'_>) -> LuaResult { + let LaunchOpts { + host, + mode, + handler, + ssh, + timeout, + } = opts; + + // First, establish a connection to an SSH server + let mut ssh_session = Ssh2Session::connect(host, ssh).to_lua_err()?; + + // Second, authenticate with the server + ssh_session.authenticate(handler).await.to_lua_err()?; + + // Third, convert our ssh session into a distant session based on desired method + let session = match mode { + Mode::Distant => ssh_session + .into_distant_session(IntoDistantSessionOpts { + timeout, + ..Default::default() + }) + .await + .to_lua_err()?, + Mode::Ssh => ssh_session.into_ssh_client_session().to_lua_err()?, + }; + + // Fourth, store our current session in our global map and then return a reference + let id = utils::rand_u32()? as usize; + SESSION_MAP + .write() + .map_err(|x| x.to_string().to_lua_err())? + .insert(id, session); + Ok(Self::new(id)) + } + + /// Connects to an already-running remote distant server + pub async fn connect(opts: ConnectOpts) -> LuaResult { + let addr = tokio::net::lookup_host(format!("{}:{}", opts.host, opts.port)) + .await + .to_lua_err()? + .next() + .ok_or_else(|| { + io::Error::new( + io::ErrorKind::AddrNotAvailable, + "Failed to resolve host & port", + ) + }) + .to_lua_err()?; + + let key: SecretKey32 = opts.key.parse().to_lua_err()?; + let codec = XChaCha20Poly1305Codec::from(key); + + let session = DistantSession::tcp_connect_timeout(addr, codec, opts.timeout) + .await + .to_lua_err()?; + + let id = utils::rand_u32()? as usize; + SESSION_MAP + .write() + .map_err(|x| x.to_string().to_lua_err())? + .insert(id, session); + Ok(Self::new(id)) + } +} + +/// impl_methods!(methods: &mut M, name: Ident) +macro_rules! impl_methods { + ($methods:expr, $name:ident) => { + impl_methods!($methods, $name, |_lua, data| {Ok(data)}); + }; + ($methods:expr, $name:ident, |$lua:ident, $data:ident| $block:block) => {{ + paste! { + $methods.add_method(stringify!([<$name:snake>]), |$lua, this, params: LuaValue| { + let params: api::[<$name:camel Params>] = $lua.from_value(params)?; + let $data = api::[<$name:snake>](get_session_channel(this.id)?, params)?; + + #[allow(unused_braces)] + $block + }); + $methods.add_async_method(stringify!([<$name:snake _async>]), |$lua, this, params: LuaValue| async move { + let rt = crate::runtime::get_runtime()?; + let params: api::[<$name:camel Params>] = $lua.from_value(params)?; + let $data = { + let tmp = rt.spawn( + api::[<$name:snake _async>](get_session_channel(this.id)?, params) + ).await; + + match tmp { + Ok(x) => x.to_lua_err(), + Err(x) => Err(x).to_lua_err(), + } + }?; + + #[allow(unused_braces)] + $block + }); + } + }}; +} + +impl UserData for Session { + fn add_fields<'lua, F: UserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("id", |_, this| Ok(this.id)); + } + + fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_method("is_active", |_, this, _: LuaValue| { + Ok(get_session_channel(this.id).is_ok()) + }); + + impl_methods!(methods, append_file); + impl_methods!(methods, append_file_text); + impl_methods!(methods, copy); + impl_methods!(methods, create_dir); + impl_methods!(methods, exists); + impl_methods!(methods, metadata, |lua, m| { to_value!(lua, &m) }); + impl_methods!(methods, read_dir, |lua, results| { + let (entries, errors) = results; + let tbl = lua.create_table()?; + tbl.set( + "entries", + entries + .iter() + .map(|x| to_value!(lua, x)) + .collect::>>()?, + )?; + tbl.set( + "errors", + errors + .iter() + .map(|x| x.to_string()) + .collect::>(), + )?; + + Ok(tbl) + }); + impl_methods!(methods, read_file); + impl_methods!(methods, read_file_text); + impl_methods!(methods, remove); + impl_methods!(methods, rename); + impl_methods!(methods, spawn, |_lua, proc| { + Ok(RemoteProcess::from_distant(proc)) + }); + impl_methods!(methods, spawn_lsp, |_lua, proc| { + Ok(RemoteLspProcess::from_distant(proc)) + }); + impl_methods!(methods, write_file); + impl_methods!(methods, write_file_text); + } +} diff --git a/distant-lua/src/session/api.rs b/distant-lua/src/session/api.rs new file mode 100644 index 0000000..03d176b --- /dev/null +++ b/distant-lua/src/session/api.rs @@ -0,0 +1,187 @@ +use crate::runtime; +use distant_core::{ + DirEntry, Error as Failure, Metadata, RemoteLspProcess, RemoteProcess, SessionChannel, + SessionChannelExt, +}; +use mlua::prelude::*; +use once_cell::sync::Lazy; +use paste::paste; +use serde::Deserialize; +use std::{path::PathBuf, time::Duration}; + +static TENANT: Lazy = Lazy::new(whoami::hostname); + +/// Default depth for reading directory +const fn default_depth() -> usize { + 1 +} + +// Default timeout in milliseconds (15 secs) +const fn default_timeout() -> u64 { + 15000 +} + +macro_rules! make_api { + ( + $name:ident, + $ret:ty, + {$($(#[$pmeta:meta])* $pname:ident: $ptype:ty),*}, + |$channel:ident, $tenant:ident, $params:ident| $block:block $(,)? + ) => { + paste! { + #[derive(Clone, Debug, Deserialize)] + pub struct [<$name:camel Params>] { + $($(#[$pmeta])* $pname: $ptype,)* + + #[serde(default = "default_timeout")] + timeout: u64, + } + + impl [<$name:camel Params>] { + fn to_timeout_duration(&self) -> Duration { + Duration::from_millis(self.timeout) + } + } + + pub fn [<$name:snake>]( + channel: SessionChannel, + params: [<$name:camel Params>], + ) -> LuaResult<$ret> { + runtime::block_on([<$name:snake _async>](channel, params)) + } + + pub async fn [<$name:snake _async>]( + channel: SessionChannel, + params: [<$name:camel Params>], + ) -> LuaResult<$ret> { + try_timeout!(params.to_timeout_duration(), async move { + let f = | + mut $channel: SessionChannel, + $tenant: &'static str, + $params: [<$name:camel Params>] + | async move $block; + f(channel, TENANT.as_str(), params).await + }) + } + } + }; +} + +make_api!(append_file, (), { path: PathBuf, data: Vec }, |channel, tenant, params| { + channel.append_file(tenant, params.path, params.data).await +}); + +make_api!(append_file_text, (), { path: PathBuf, data: String }, |channel, tenant, params| { + channel.append_file_text(tenant, params.path, params.data).await +}); + +make_api!(copy, (), { src: PathBuf, dst: PathBuf }, |channel, tenant, params| { + channel.copy(tenant, params.src, params.dst).await +}); + +make_api!(create_dir, (), { path: PathBuf, #[serde(default)] all: bool }, |channel, tenant, params| { + channel.create_dir(tenant, params.path, params.all).await +}); + +make_api!( + exists, + bool, + { path: PathBuf }, + |channel, tenant, params| { channel.exists(tenant, params.path).await } +); + +make_api!( + metadata, + Metadata, + { + path: PathBuf, + #[serde(default)] canonicalize: bool, + #[serde(default)] resolve_file_type: bool + }, + |channel, tenant, params| { + channel.metadata( + tenant, + params.path, + params.canonicalize, + params.resolve_file_type + ).await + } +); + +make_api!( + read_dir, + (Vec, Vec), + { + path: PathBuf, + #[serde(default = "default_depth")] depth: usize, + #[serde(default)] absolute: bool, + #[serde(default)] canonicalize: bool, + #[serde(default)] include_root: bool + }, + |channel, tenant, params| { + channel.read_dir( + tenant, + params.path, + params.depth, + params.absolute, + params.canonicalize, + params.include_root, + ).await + } +); + +make_api!( + read_file, + Vec, + { path: PathBuf }, + |channel, tenant, params| { channel.read_file(tenant, params.path).await } +); + +make_api!( + read_file_text, + String, + { path: PathBuf }, + |channel, tenant, params| { channel.read_file_text(tenant, params.path).await } +); + +make_api!( + remove, + (), + { path: PathBuf, #[serde(default)] force: bool }, + |channel, tenant, params| { channel.remove(tenant, params.path, params.force).await } +); + +make_api!( + rename, + (), + { src: PathBuf, dst: PathBuf }, + |channel, tenant, params| { channel.rename(tenant, params.src, params.dst).await } +); + +make_api!( + spawn, + RemoteProcess, + { cmd: String, args: Vec }, + |channel, tenant, params| { channel.spawn(tenant, params.cmd, params.args).await } +); + +make_api!( + spawn_lsp, + RemoteLspProcess, + { cmd: String, args: Vec }, + |channel, tenant, params| { channel.spawn_lsp(tenant, params.cmd, params.args).await } +); + +make_api!( + write_file, + (), + { path: PathBuf, data: Vec }, + |channel, tenant, params| { channel.write_file(tenant, params.path, params.data).await } +); + +make_api!( + write_file_text, + (), + { path: PathBuf, data: String }, + |channel, tenant, params| { channel.write_file_text(tenant, params.path, params.data).await } +); diff --git a/distant-lua/src/session/opts.rs b/distant-lua/src/session/opts.rs new file mode 100644 index 0000000..84f147b --- /dev/null +++ b/distant-lua/src/session/opts.rs @@ -0,0 +1,208 @@ +use distant_ssh2::{Ssh2AuthHandler, Ssh2SessionOpts}; +use mlua::prelude::*; +use serde::Deserialize; +use std::{fmt, io, time::Duration}; + +#[derive(Clone, Debug, Default)] +pub struct ConnectOpts { + pub host: String, + pub port: u16, + pub key: String, + pub timeout: Duration, +} + +impl<'lua> FromLua<'lua> for ConnectOpts { + fn from_lua(lua_value: LuaValue<'lua>, _: &'lua Lua) -> LuaResult { + match lua_value { + LuaValue::Table(tbl) => Ok(Self { + host: tbl.get("host")?, + port: tbl.get("port")?, + key: tbl.get("key")?, + timeout: { + let milliseconds: u64 = tbl.get("timeout")?; + Duration::from_millis(milliseconds) + }, + }), + LuaValue::Nil => Err(LuaError::FromLuaConversionError { + from: "Nil", + to: "ConnectOpts", + message: None, + }), + LuaValue::Boolean(_) => Err(LuaError::FromLuaConversionError { + from: "Boolean", + to: "ConnectOpts", + message: None, + }), + LuaValue::LightUserData(_) => Err(LuaError::FromLuaConversionError { + from: "LightUserData", + to: "ConnectOpts", + message: None, + }), + LuaValue::Integer(_) => Err(LuaError::FromLuaConversionError { + from: "Integer", + to: "ConnectOpts", + message: None, + }), + LuaValue::Number(_) => Err(LuaError::FromLuaConversionError { + from: "Number", + to: "ConnectOpts", + message: None, + }), + LuaValue::String(_) => Err(LuaError::FromLuaConversionError { + from: "String", + to: "ConnectOpts", + message: None, + }), + LuaValue::Function(_) => Err(LuaError::FromLuaConversionError { + from: "Function", + to: "ConnectOpts", + message: None, + }), + LuaValue::Thread(_) => Err(LuaError::FromLuaConversionError { + from: "Thread", + to: "ConnectOpts", + message: None, + }), + LuaValue::UserData(_) => Err(LuaError::FromLuaConversionError { + from: "UserData", + to: "ConnectOpts", + message: None, + }), + LuaValue::Error(_) => Err(LuaError::FromLuaConversionError { + from: "Error", + to: "ConnectOpts", + message: None, + }), + } + } +} + +#[derive(Default)] +pub struct LaunchOpts<'a> { + pub host: String, + pub mode: Mode, + pub handler: Ssh2AuthHandler<'a>, + pub ssh: Ssh2SessionOpts, + pub timeout: Duration, +} + +impl fmt::Debug for LaunchOpts<'_> { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + f.debug_struct("LaunchOpts") + .field("host", &self.host) + .field("mode", &self.mode) + .field("handler", &"...") + .field("ssh", &self.ssh) + .field("timeout", &self.timeout) + .finish() + } +} + +impl<'lua> FromLua<'lua> for LaunchOpts<'lua> { + fn from_lua(lua_value: LuaValue<'lua>, lua: &'lua Lua) -> LuaResult { + match lua_value { + LuaValue::Table(tbl) => Ok(Self { + host: tbl.get("host")?, + mode: lua.from_value(tbl.get("mode")?)?, + handler: Ssh2AuthHandler { + on_authenticate: { + let f: LuaFunction = tbl.get("on_authenticate")?; + Box::new(move |ev| { + let value = to_value!(lua, &ev) + .map_err(|x| io::Error::new(io::ErrorKind::InvalidData, x))?; + f.call::>(value) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x)) + }) + }, + on_banner: { + let f: LuaFunction = tbl.get("on_banner")?; + Box::new(move |banner| { + let _ = f.call::(banner.to_string()); + }) + }, + on_host_verify: { + let f: LuaFunction = tbl.get("on_host_verify")?; + Box::new(move |host| { + f.call::(host.to_string()) + .map_err(|x| io::Error::new(io::ErrorKind::Other, x)) + }) + }, + on_error: { + let f: LuaFunction = tbl.get("on_error")?; + Box::new(move |err| { + let _ = f.call::(err.to_string()); + }) + }, + }, + ssh: lua.from_value(tbl.get("ssh")?)?, + timeout: { + let milliseconds: u64 = tbl.get("timeout")?; + Duration::from_millis(milliseconds) + }, + }), + LuaValue::Nil => Err(LuaError::FromLuaConversionError { + from: "Nil", + to: "LaunchOpts", + message: None, + }), + LuaValue::Boolean(_) => Err(LuaError::FromLuaConversionError { + from: "Boolean", + to: "LaunchOpts", + message: None, + }), + LuaValue::LightUserData(_) => Err(LuaError::FromLuaConversionError { + from: "LightUserData", + to: "LaunchOpts", + message: None, + }), + LuaValue::Integer(_) => Err(LuaError::FromLuaConversionError { + from: "Integer", + to: "LaunchOpts", + message: None, + }), + LuaValue::Number(_) => Err(LuaError::FromLuaConversionError { + from: "Number", + to: "LaunchOpts", + message: None, + }), + LuaValue::String(_) => Err(LuaError::FromLuaConversionError { + from: "String", + to: "LaunchOpts", + message: None, + }), + LuaValue::Function(_) => Err(LuaError::FromLuaConversionError { + from: "Function", + to: "LaunchOpts", + message: None, + }), + LuaValue::Thread(_) => Err(LuaError::FromLuaConversionError { + from: "Thread", + to: "LaunchOpts", + message: None, + }), + LuaValue::UserData(_) => Err(LuaError::FromLuaConversionError { + from: "UserData", + to: "LaunchOpts", + message: None, + }), + LuaValue::Error(_) => Err(LuaError::FromLuaConversionError { + from: "Error", + to: "LaunchOpts", + message: None, + }), + } + } +} + +#[derive(Copy, Clone, Debug, Deserialize)] +#[serde(rename_all = "snake_case")] +pub enum Mode { + Distant, + Ssh, +} + +impl Default for Mode { + fn default() -> Self { + Self::Distant + } +} diff --git a/distant-lua/src/session/proc.rs b/distant-lua/src/session/proc.rs new file mode 100644 index 0000000..1a105be --- /dev/null +++ b/distant-lua/src/session/proc.rs @@ -0,0 +1,194 @@ +use crate::runtime; +use distant_core::{ + RemoteLspProcess as DistantRemoteLspProcess, RemoteProcess as DistantRemoteProcess, +}; +use mlua::{prelude::*, UserData, UserDataFields, UserDataMethods}; +use once_cell::sync::Lazy; +use std::{collections::HashMap, io}; +use tokio::sync::RwLock; + +/// Contains mapping of id -> remote process for use in maintaining active processes +static PROC_MAP: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +/// Contains mapping of id -> remote lsp process for use in maintaining active processes +static LSP_PROC_MAP: Lazy>> = + Lazy::new(|| RwLock::new(HashMap::new())); + +macro_rules! with_proc { + ($map_name:ident, $id:expr, $proc:ident -> $f:expr) => {{ + let id = $id; + let mut lock = runtime::get_runtime()?.block_on($map_name.write()); + let $proc = lock.get_mut(&id).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("No remote process found with id {}", id), + ) + .to_lua_err() + })?; + $f + }}; +} + +macro_rules! with_proc_async { + ($map_name:ident, $id:expr, $proc:ident -> $f:expr) => {{ + let id = $id; + let mut lock = $map_name.write().await; + let $proc = lock.get_mut(&id).ok_or_else(|| { + io::Error::new( + io::ErrorKind::NotFound, + format!("No remote process found with id {}", id), + ) + .to_lua_err() + })?; + $f + }}; +} + +macro_rules! impl_process { + ($name:ident, $type:ty, $map_name:ident) => { + #[derive(Copy, Clone, Debug)] + pub struct $name { + id: usize, + } + + impl $name { + pub fn new(id: usize) -> Self { + Self { id } + } + + pub fn from_distant(proc: $type) -> LuaResult { + let id = proc.id(); + runtime::get_runtime()?.block_on($map_name.write()).insert(id, proc); + Ok(Self::new(id)) + } + + fn is_active(id: usize) -> LuaResult { + Ok(runtime::get_runtime()?.block_on($map_name.read()).contains_key(&id)) + } + + fn write_stdin(id: usize, data: String) -> LuaResult<()> { + runtime::block_on(Self::write_stdin_async(id, data)) + } + + async fn write_stdin_async(id: usize, data: String) -> LuaResult<()> { + with_proc_async!($map_name, id, proc -> { + proc.stdin + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "Stdin closed").to_lua_err() + })? + .write(data.as_str()) + .await + .to_lua_err() + }) + } + + fn close_stdin(id: usize) -> LuaResult<()> { + with_proc!($map_name, id, proc -> { + let _ = proc.stdin.take(); + Ok(()) + }) + } + + fn read_stdout(id: usize) -> LuaResult> { + with_proc!($map_name, id, proc -> { + proc.stdout + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "Stdout closed").to_lua_err() + })? + .try_read() + .to_lua_err() + }) + } + + async fn read_stdout_async(id: usize) -> LuaResult { + with_proc_async!($map_name, id, proc -> { + proc.stdout + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "Stdout closed").to_lua_err() + })? + .read() + .await + .to_lua_err() + }) + } + + fn read_stderr(id: usize) -> LuaResult> { + with_proc!($map_name, id, proc -> { + proc.stderr + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "Stderr closed").to_lua_err() + })? + .try_read() + .to_lua_err() + }) + } + + async fn read_stderr_async(id: usize) -> LuaResult { + with_proc_async!($map_name, id, proc -> { + proc.stderr + .as_mut() + .ok_or_else(|| { + io::Error::new(io::ErrorKind::BrokenPipe, "Stderr closed").to_lua_err() + })? + .read() + .await + .to_lua_err() + }) + } + + fn kill(id: usize) -> LuaResult<()> { + runtime::block_on(Self::kill_async(id)) + } + + async fn kill_async(id: usize) -> LuaResult<()> { + with_proc_async!($map_name, id, proc -> { + proc.kill().await.to_lua_err() + }) + } + + fn abort(id: usize) -> LuaResult<()> { + with_proc!($map_name, id, proc -> { + Ok(proc.abort()) + }) + } + } + + impl UserData for $name { + fn add_fields<'lua, F: UserDataFields<'lua, Self>>(fields: &mut F) { + fields.add_field_method_get("id", |_, this| Ok(this.id)); + } + + fn add_methods<'lua, M: UserDataMethods<'lua, Self>>(methods: &mut M) { + methods.add_method("is_active", |_, this, ()| Self::is_active(this.id)); + methods.add_method("close_stdin", |_, this, ()| Self::close_stdin(this.id)); + methods.add_method("write_stdin", |_, this, data: String| { + Self::write_stdin(this.id, data) + }); + methods.add_async_method("write_stdin_async", |_, this, data: String| { + runtime::spawn(Self::write_stdin_async(this.id, data)) + }); + methods.add_method("read_stdout", |_, this, ()| Self::read_stdout(this.id)); + methods.add_async_method("read_stdout_async", |_, this, ()| { + runtime::spawn(Self::read_stdout_async(this.id)) + }); + methods.add_method("read_stderr", |_, this, ()| Self::read_stderr(this.id)); + methods.add_async_method("read_stderr_async", |_, this, ()| { + runtime::spawn(Self::read_stderr_async(this.id)) + }); + methods.add_method("kill", |_, this, ()| Self::kill(this.id)); + methods.add_async_method("kill_async", |_, this, ()| { + runtime::spawn(Self::kill_async(this.id)) + }); + methods.add_method("abort", |_, this, ()| Self::abort(this.id)); + } + } + }; +} + +impl_process!(RemoteProcess, DistantRemoteProcess, PROC_MAP); +impl_process!(RemoteLspProcess, DistantRemoteLspProcess, LSP_PROC_MAP); diff --git a/distant-lua/src/utils.rs b/distant-lua/src/utils.rs new file mode 100644 index 0000000..d628aec --- /dev/null +++ b/distant-lua/src/utils.rs @@ -0,0 +1,113 @@ +use mlua::{chunk, prelude::*}; +use once_cell::sync::OnceCell; +use oorandom::Rand32; +use std::{ + sync::Mutex, + time::{SystemTime, SystemTimeError, UNIX_EPOCH}, +}; + +/// Makes a Lua table containing the utils functions +pub fn make_utils_tbl(lua: &Lua) -> LuaResult { + let tbl = lua.create_table()?; + + tbl.set( + "nvim_wrap_async", + lua.create_function(|lua, async_fn| nvim_wrap_async(lua, async_fn))?, + )?; + tbl.set( + "wrap_async", + lua.create_function(|lua, (async_fn, schedule_fn)| wrap_async(lua, async_fn, schedule_fn))?, + )?; + tbl.set("rand_u32", lua.create_function(|_, ()| rand_u32())?)?; + + Ok(tbl) +} + +/// Specialty function that performs wrap_async using `vim.schedule` from neovim +pub fn nvim_wrap_async<'a>(lua: &'a Lua, async_fn: LuaFunction<'a>) -> LuaResult> { + let schedule_fn = lua.load("vim.schedule").eval()?; + wrap_async(lua, async_fn, schedule_fn) +} + +/// Wraps an async function and a scheduler function such that +/// a new function is returned that takes a callback when the async +/// function completes as well as zero or more arguments to provide +/// to the async function when first executing it +/// +/// ```lua +/// local f = wrap_async(some_async_fn, schedule_fn) +/// f(arg1, arg2, ..., function(success, res) end) +/// ``` +pub fn wrap_async<'a>( + lua: &'a Lua, + async_fn: LuaFunction<'a>, + schedule_fn: LuaFunction<'a>, +) -> LuaResult> { + let pending = pending(lua)?; + lua.load(chunk! { + return function(...) + local args = {...} + local cb = table.remove(args) + + assert(type(cb) == "function", "Invalid type for cb") + local schedule = function(...) return $schedule_fn(...) end + + // Wrap the async function in a coroutine so we can poll it + local thread = coroutine.create(function(...) return $async_fn(...) end) + + // Start the future by peforming the first poll + local status, res = coroutine.resume(thread, unpack(args)) + + local inner_fn + inner_fn = function() + // Thread has exited already, so res is an error + if not status then + cb(false, res) + // Got pending status on success, so we are still waiting + elseif res == $pending then + // Resume the coroutine and then schedule a followup + // once it has completed another round + status, res = coroutine.resume(thread) + schedule(inner_fn) + // Got success with non-pending status, so this should be the result + else + cb(true, res) + end + end + schedule(inner_fn) + end + }) + .eval() +} + +/// Return mlua's internal `Poll::Pending` +pub(super) fn pending(lua: &Lua) -> LuaResult { + let pending = lua.create_async_function(|_, ()| async move { + tokio::task::yield_now().await; + Ok(()) + })?; + + // Should return mlua's internal Poll::Pending that is statically available + // See https://github.com/khvzak/mlua/issues/76#issuecomment-932645078 + lua.load(chunk! { + (coroutine.wrap($pending))() + }) + .eval() +} + +/// Return a random u32 +pub fn rand_u32() -> LuaResult { + static RAND: OnceCell> = OnceCell::new(); + + Ok(RAND + .get_or_try_init::<_, SystemTimeError>(|| { + Ok(Mutex::new(Rand32::new( + SystemTime::now().duration_since(UNIX_EPOCH)?.as_secs(), + ))) + }) + .to_lua_err()? + .lock() + .map_err(|x| x.to_string()) + .to_lua_err()? + .rand_u32()) +} diff --git a/distant-ssh2/Cargo.toml b/distant-ssh2/Cargo.toml index 1dfdd03..fb62b38 100644 --- a/distant-ssh2/Cargo.toml +++ b/distant-ssh2/Cargo.toml @@ -17,6 +17,7 @@ futures = "0.3.16" log = "0.4.14" rand = { version = "0.8.4", features = ["getrandom"] } rpassword = "5.0.1" +shell-words = "1.0" 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" } diff --git a/distant-ssh2/src/handler.rs b/distant-ssh2/src/handler.rs index 91dd920..904ff0b 100644 --- a/distant-ssh2/src/handler.rs +++ b/distant-ssh2/src/handler.rs @@ -9,7 +9,7 @@ use std::{ collections::HashMap, future::Future, io::{self, Read, Write}, - path::{Component, Path, PathBuf}, + path::{Component, PathBuf}, pin::Pin, sync::Arc, }; @@ -215,7 +215,7 @@ async fn file_append( use smol::io::AsyncWriteExt; let mut file = session .sftp() - .open_mode( + .open_with_mode( path, OpenOptions { read: false, @@ -246,7 +246,12 @@ async fn dir_read( 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)?; + let root_path = sftp + .canonicalize(path) + .compat() + .await + .map_err(to_other_error)? + .into_std_path_buf(); // Build up our entry list let mut entries = Vec::new(); @@ -277,8 +282,8 @@ async fn dir_read( let is_dir = match ft { FileType::Dir => true, FileType::File => false, - FileType::Symlink => match sftp.stat(&path).await { - Ok(stat) => stat.is_dir(), + FileType::Symlink => match sftp.metadata(path.to_path_buf()).await { + Ok(metadata) => metadata.is_dir(), Err(x) => { errors.push(DistantError::from(to_other_error(x))); continue; @@ -288,13 +293,18 @@ async fn dir_read( // 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) { + match sftp + .read_dir(path.to_path_buf()) + .compat() + .await + .map_err(to_other_error) + { Ok(entries) => { - for (mut path, stat) in entries { + for (mut path, metadata) in entries { // Canonicalize the path if specified, otherwise just return // the path as is path = if canonicalize { - match sftp.realpath(path).compat().await { + match sftp.canonicalize(path).compat().await { Ok(path) => path, Err(x) => { errors.push(DistantError::from(to_other_error(x))); @@ -313,13 +323,13 @@ async fn dir_read( // the path if the strip_prefix fails path = path .strip_prefix(root_path.as_path()) - .map(Path::to_path_buf) + .map(|p| p.to_path_buf()) .unwrap_or(path); }; - let ft = stat.ty; + let ft = metadata.ty; to_traverse.push(DirEntry { - path, + path: path.into_std_path_buf(), file_type: if ft.is_dir() { FileType::Dir } else if ft.is_file() { @@ -350,7 +360,7 @@ async fn dir_create(session: WezSession, path: PathBuf, all: bool) -> io::Result 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) + sftp.create_dir(path, 0o755) .compat() .await .map_err(to_other_error) @@ -391,20 +401,20 @@ async fn remove(session: WezSession, path: PathBuf, force: bool) -> io::Result io::Result io::Result 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(); + let exists = session.sftp().symlink_metadata(path).compat().await.is_ok(); Ok(Outgoing::from(ResponseData::Exists(exists))) } @@ -535,24 +545,28 @@ async fn metadata( let sftp = session.sftp(); let canonicalized_path = if canonicalize { Some( - sftp.realpath(path.to_path_buf()) + sftp.canonicalize(path.to_path_buf()) .compat() .await - .map_err(to_other_error)?, + .map_err(to_other_error)? + .into_std_path_buf(), ) } else { None }; - let stat = if resolve_file_type { - sftp.stat(path).compat().await.map_err(to_other_error)? + let metadata = if resolve_file_type { + sftp.metadata(path).compat().await.map_err(to_other_error)? } else { - sftp.lstat(path).compat().await.map_err(to_other_error)? + sftp.symlink_metadata(path) + .compat() + .await + .map_err(to_other_error)? }; - let file_type = if stat.is_dir() { + let file_type = if metadata.is_dir() { FileType::Dir - } else if stat.is_file() { + } else if metadata.is_file() { FileType::File } else { FileType::Symlink @@ -561,11 +575,11 @@ async fn metadata( Ok(Outgoing::from(ResponseData::Metadata(Metadata { canonicalized_path, file_type, - len: stat.len(), + len: metadata.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), + readonly: metadata.is_readonly(), + accessed: metadata.accessed.map(u128::from), + modified: metadata.modified.map(u128::from), created: None, }))) } @@ -852,10 +866,11 @@ async fn proc_list(_session: WezSession, state: Arc>) -> io::Result async fn system_info(session: WezSession) -> io::Result { let current_dir = session .sftp() - .realpath(".") + .canonicalize(".") .compat() .await - .map_err(to_other_error)?; + .map_err(to_other_error)? + .into_std_path_buf(); let first_component = current_dir.components().next(); let is_windows = diff --git a/distant-ssh2/src/lib.rs b/distant-ssh2/src/lib.rs index 6821c0b..71cec1c 100644 --- a/distant-ssh2/src/lib.rs +++ b/distant-ssh2/src/lib.rs @@ -1,5 +1,7 @@ use async_compat::CompatExt; -use distant_core::{Request, Session, Transport}; +use distant_core::{ + Request, Session, SessionChannelExt, SessionInfo, Transport, XChaCha20Poly1305Codec, +}; use log::*; use smol::channel::Receiver as SmolReceiver; use std::{ @@ -7,12 +9,14 @@ use std::{ io::{self, Write}, path::PathBuf, sync::Arc, + time::Duration, }; use tokio::sync::{mpsc, Mutex}; use wezterm_ssh::{Config as WezConfig, Session as WezSession, SessionEvent as WezSessionEvent}; mod handler; +/// Represents a singular authentication prompt for a new ssh session #[derive(Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Ssh2AuthPrompt { @@ -24,6 +28,8 @@ pub struct Ssh2AuthPrompt { pub echo: bool, } +/// Represents an authentication request that needs to be handled before an ssh session can be +/// established #[derive(Debug)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Ssh2AuthEvent { @@ -37,6 +43,7 @@ pub struct Ssh2AuthEvent { pub prompts: Vec, } +/// Represents options to be provided when establishing an ssh session #[derive(Clone, Debug, Default)] #[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] pub struct Ssh2SessionOpts { @@ -74,10 +81,46 @@ pub struct Ssh2SessionOpts { pub other: BTreeMap, } +/// Represents options to be provided when converting an ssh session into a distant session +#[derive(Clone, Debug)] +pub struct IntoDistantSessionOpts { + /// Binary to use for distant server + pub binary: String, + + /// Arguments to supply to the distant server when starting it + pub args: String, + + /// Timeout to use when connecting to the distant server + pub timeout: Duration, +} + +impl Default for IntoDistantSessionOpts { + fn default() -> Self { + Self { + binary: String::from("distant"), + args: String::new(), + timeout: Duration::from_secs(15), + } + } +} + +/// Represents callback functions to be invoked during authentication of an ssh session pub struct Ssh2AuthHandler<'a> { + /// Invoked whenever a series of authentication prompts need to be displayed and responded to, + /// receiving one event at a time and returning a collection of answers matching the total + /// prompts provided in the event pub on_authenticate: Box io::Result> + 'a>, + + /// Invoked when receiving a banner from the ssh server, receiving the banner as a str, useful + /// to display to the user pub on_banner: Box, + + /// Invoked when the host is unknown for a new ssh connection, receiving the host as a str and + /// returning true if the host is acceptable or false if the host (and thereby ssh session) + /// should be declined pub on_host_verify: Box io::Result + 'a>, + + /// Invoked when an error is encountered, receiving the error as a str pub on_error: Box, } @@ -134,9 +177,11 @@ impl Default for Ssh2AuthHandler<'static> { } } +/// Represents an ssh2 session pub struct Ssh2Session { session: WezSession, events: SmolReceiver, + authenticated: bool, } impl Ssh2Session { @@ -196,11 +241,25 @@ impl Ssh2Session { let (session, events) = WezSession::connect(config).map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; - Ok(Self { session, events }) + Ok(Self { + session, + events, + authenticated: false, + }) } - /// Authenticates the [`Ssh2Session`] and produces a [`Session`] - pub async fn authenticate(self, mut handler: Ssh2AuthHandler<'_>) -> io::Result { + #[inline] + pub fn is_authenticated(&self) -> bool { + self.authenticated + } + + /// Authenticates the [`Ssh2Session`] if not already authenticated + pub async fn authenticate(&mut self, mut handler: Ssh2AuthHandler<'_>) -> io::Result<()> { + // If already authenticated, exit + if self.authenticated { + return Ok(()); + } + // Perform the authentication by listening for events and continuing to handle them // until authenticated while let Ok(event) = self.events.recv().await { @@ -246,12 +305,92 @@ impl Ssh2Session { } } - // We are now authenticated, so convert into a distant session that wraps our ssh2 session - self.into_session() + // Mark as authenticated + self.authenticated = true; + + Ok(()) + } + + /// Consume [`Ssh2Session`] and produce a distant [`Session`] that is connected to a remote + /// distant server that is spawned using the ssh session + pub async fn into_distant_session(self, opts: IntoDistantSessionOpts) -> io::Result { + // Exit early if not authenticated as this is a requirement + if !self.authenticated { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Not authenticated", + )); + } + + let mut session = self.into_ssh_client_session()?; + + // Build arguments for distant + let mut args = vec![String::from("listen"), String::from("--daemon")]; + args.extend( + shell_words::split(&opts.args) + .map_err(|x| io::Error::new(io::ErrorKind::InvalidInput, x))?, + ); + + // Spawn distant server + let mut proc = session + .spawn("", opts.binary, args) + .await + .map_err(|x| io::Error::new(io::ErrorKind::Other, x))?; + let mut stdout = proc.stdout.take().unwrap(); + let (success, code) = proc + .wait() + .await + .map_err(|x| io::Error::new(io::ErrorKind::BrokenPipe, x))?; + + // Close out ssh session + session.abort(); + let _ = session.wait().await; + + // If successful, grab the session information and establish a connection + // with the distant server + if success { + let mut out = String::new(); + while let Ok(data) = stdout.read().await { + out.push_str(&data); + } + let maybe_info = out + .lines() + .find_map(|line| line.parse::().ok()); + match maybe_info { + Some(info) => { + let addr = info.to_socket_addr().await?; + let key = info.key; + let codec = XChaCha20Poly1305Codec::from(key); + Session::tcp_connect_timeout(addr, codec, opts.timeout).await + } + None => Err(io::Error::new( + io::ErrorKind::InvalidData, + "Missing session data", + )), + } + } else { + Err(io::Error::new( + io::ErrorKind::Other, + format!( + "Spawning distant failed: {}", + code.map(|x| x.to_string()) + .unwrap_or_else(|| String::from("???")) + ), + )) + } } - /// Consume [`Ssh2Session`] and produce a distant [`Session`] - fn into_session(self) -> io::Result { + /// Consume [`Ssh2Session`] and produce a distant [`Session`] that is powered by an ssh client + /// underneath + pub fn into_ssh_client_session(self) -> io::Result { + // Exit early if not authenticated as this is a requirement + if !self.authenticated { + return Err(io::Error::new( + io::ErrorKind::PermissionDenied, + "Not authenticated", + )); + } + let (t1, t2) = Transport::pair(1); let session = Session::initialize(t1)?; diff --git a/distant-ssh2/tests/sshd.rs b/distant-ssh2/tests/sshd.rs index 7c7a121..7e6baa6 100644 --- a/distant-ssh2/tests/sshd.rs +++ b/distant-ssh2/tests/sshd.rs @@ -404,7 +404,7 @@ pub fn sshd() -> &'static Sshd { pub async fn session(sshd: &'_ Sshd, _logger: &'_ flexi_logger::LoggerHandle) -> Session { let port = sshd.port; - Ssh2Session::connect( + let mut ssh2_session = Ssh2Session::connect( "127.0.0.1", Ssh2SessionOpts { port: Some(port), @@ -415,18 +415,22 @@ pub async fn session(sshd: &'_ Sshd, _logger: &'_ flexi_logger::LoggerHandle) -> ..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() + .unwrap(); + + ssh2_session + .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(); + + ssh2_session.into_ssh_client_session().unwrap() } diff --git a/scripts/mac-m1-cross-build.sh b/scripts/mac-m1-cross-build.sh index d706406..d3f99d6 100755 --- a/scripts/mac-m1-cross-build.sh +++ b/scripts/mac-m1-cross-build.sh @@ -29,7 +29,7 @@ popd () { } ############################################################################### -# TARGET GENERATION +# TARGET GENERATION FOR DISTANT BIN # # Note: This is running on an M1 Mac and expects tooling like `lipo` ############################################################################### @@ -42,13 +42,13 @@ mkdir -p "${PACKAGE_DIR}" # Apple x86-64 on M1 Mac TARGET="x86_64-apple-darwin" -echo "Building ${TARGET}" +echo "Building ${TARGET} distant binary" cargo build --release --target "${TARGET}" strip "${TARGET_DIR}/${TARGET}/release/distant" # Apple ARM on M1 Mac TARGET="aarch64-apple-darwin" -echo "Building ${TARGET}" +echo "Building ${TARGET} distant binary" cargo build --release --target "${TARGET}" strip "${TARGET_DIR}/${TARGET}/release/distant" @@ -61,14 +61,14 @@ lipo -create \ # Linux x86-64 (libc) on M1 Mac TARGET="x86_64-unknown-linux-gnu" -echo "Building ${TARGET}" +echo "Building ${TARGET} distant binary" cargo build --release --target "${TARGET}" cp "${TARGET_DIR}/${TARGET}/release/distant" "${PACKAGE_DIR}/distant-linux64-gnu" x86_64-unknown-linux-musl-strip "${PACKAGE_DIR}/distant-linux64-gnu" # Linux x86-64 (musl) on M1 Mac TARGET="x86_64-unknown-linux-musl" -echo "Building ${TARGET}" +echo "Building ${TARGET} distant binary" cargo build --release --target "${TARGET}" cp "${TARGET_DIR}/${TARGET}/release/distant" "${PACKAGE_DIR}/distant-linux64-musl" x86_64-unknown-linux-musl-strip "${PACKAGE_DIR}/distant-linux64-musl" @@ -84,6 +84,31 @@ for bin in *; do done popd +############################################################################### +# TARGET GENERATION FOR DISTANT LUA MODULE +############################################################################### + +# Apple x86-64 on M1 Mac +TARGETS=( + "x86_64-apple-darwin" + "aarch64-apple-darwin" + "x86_64-unknown-linux-gnu" +) +pushd "distant-lua"; +for TARGET in "${TARGETS[@]}"; do + echo "Building ${TARGET} for Lua" + cargo build --release --target "${TARGET}" + + if [ "$TARGET" == "x86_64-apple-darwin" ]; then + cp "${TARGET_DIR}/${TARGET}/release/libdistant_lua.dylib" "${PACKAGE_DIR}/distant_lua-macos-x86_64.so" + elif [ "$TARGET" == "aarch64-apple-darwin" ]; then + cp "${TARGET_DIR}/${TARGET}/release/libdistant_lua.dylib" "${PACKAGE_DIR}/distant_lua-macos-aarch64.so" + else + cp "${TARGET_DIR}/${TARGET}/release/libdistant_lua.so" "${PACKAGE_DIR}/distant_lua-linux-x86_64.so" + fi +done +popd + ############################################################################### # SHA 256 GENERATION # diff --git a/scripts/release.sh b/scripts/release.sh index 40d61da..99b99df 100755 --- a/scripts/release.sh +++ b/scripts/release.sh @@ -6,6 +6,7 @@ CRATES=( distant-core distant-ssh2 + distant-lua distant ) diff --git a/src/opt.rs b/src/opt.rs index 1aab2e4..f6a3f6b 100644 --- a/src/opt.rs +++ b/src/opt.rs @@ -21,7 +21,7 @@ use strum::{EnumString, EnumVariantNames, IntoStaticStr, VariantNames}; static USERNAME: Lazy = Lazy::new(whoami::username); /// Options and commands to apply to binary -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] #[structopt(name = "distant")] pub struct Opt { #[structopt(flatten)] @@ -74,7 +74,7 @@ impl LogLevel { } /// Contains options that are common across subcommands -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] pub struct CommonOpt { /// Quiet mode, suppresses all logging (shortcut for log level off) #[structopt(short, long, global = true)] @@ -108,7 +108,7 @@ impl CommonOpt { } /// Contains options related sessions -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] pub struct SessionOpt { /// Represents the location of the file containing session information, /// only useful when the session is set to "file" @@ -137,7 +137,7 @@ pub struct SshConnectionOpts { pub user: Option, } -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] pub enum Subcommand { /// Performs some action on a remote machine Action(ActionSubcommand), @@ -232,7 +232,7 @@ pub enum Format { } /// Represents subcommand to execute some operation remotely -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] #[structopt(verbatim_doc_comment)] pub struct ActionSubcommand { /// Represents the format that results should be returned @@ -441,7 +441,7 @@ impl Default for SessionInput { } /// Represents subcommand to launch a remote server -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] pub struct LaunchSubcommand { /// Represents the medium for sharing the session upon launching on a remote machine #[structopt( @@ -551,7 +551,7 @@ impl LaunchSubcommand { } /// Represents subcommand to operate in listen mode for incoming requests -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] pub struct ListenSubcommand { /// Runs in background via daemon-mode (does nothing on windows) #[structopt(short, long)] @@ -613,7 +613,7 @@ impl ListenSubcommand { } /// Represents subcommand to execute some LSP server on a remote machine -#[derive(Debug, StructOpt)] +#[derive(Clone, Debug, StructOpt)] #[structopt(verbatim_doc_comment)] pub struct LspSubcommand { /// Represents the format that results should be returned diff --git a/src/subcommand/launch.rs b/src/subcommand/launch.rs index 396b474..a99c22c 100644 --- a/src/subcommand/launch.rs +++ b/src/subcommand/launch.rs @@ -9,7 +9,6 @@ use distant_core::{ PlainCodec, RelayServer, Session, SessionInfo, SessionInfoFile, Transport, TransportListener, XChaCha20Poly1305Codec, }; -use fork::{daemon, Fork}; use log::*; use std::{path::Path, string::FromUtf8Error}; use tokio::{io, process::Command, runtime::Runtime, time::Duration}; @@ -63,6 +62,7 @@ pub fn run(cmd: LaunchSubcommand, opt: CommonOpt) -> Result<(), Error> { debug!("Piping session to stdout"); println!("{}", session.to_unprotected_string()) } + #[cfg(unix)] SessionOutput::Socket if is_daemon => { debug!( "Forking and entering interactive loop over unix socket {:?}", @@ -73,26 +73,13 @@ pub fn run(cmd: LaunchSubcommand, opt: CommonOpt) -> Result<(), Error> { // this produces a garbage process that won't die drop(rt); - match daemon(false, false) { - Ok(Fork::Child) => { - // NOTE: We need to create a runtime within the forked process as - // tokio's runtime doesn't support being transferred from - // parent to child in a fork - let rt = Runtime::new()?; - rt.block_on(async { - socket_loop( - session_socket, - session, - timeout, - fail_if_socket_exists, - shutdown_after, - ) - .await - })? - } - Ok(_) => {} - Err(x) => return Err(Error::Fork(x)), - } + run_daemon_socket( + session_socket, + session, + timeout, + fail_if_socket_exists, + shutdown_after, + )?; } #[cfg(unix)] SessionOutput::Socket => { @@ -111,14 +98,39 @@ pub fn run(cmd: LaunchSubcommand, opt: CommonOpt) -> Result<(), Error> { .await })? } - #[cfg(not(unix))] - SessionOutput::Socket => { - debug!(concat!( - "Trying to enter interactive loop over unix socket, ", - "but not on unix platform!" - )); - unreachable!() + } + + Ok(()) +} + +#[cfg(unix)] +fn run_daemon_socket( + session_socket: impl AsRef, + session: SessionInfo, + timeout: Duration, + fail_if_socket_exists: bool, + shutdown_after: Option, +) -> Result<(), Error> { + use fork::{daemon, Fork}; + match daemon(false, false) { + Ok(Fork::Child) => { + // NOTE: We need to create a runtime within the forked process as + // tokio's runtime doesn't support being transferred from + // parent to child in a fork + let rt = Runtime::new()?; + rt.block_on(async { + socket_loop( + session_socket, + session, + timeout, + fail_if_socket_exists, + shutdown_after, + ) + .await + })? } + Ok(_) => {} + Err(x) => return Err(Error::Fork(x)), } Ok(()) @@ -136,6 +148,7 @@ async fn keep_loop(info: SessionInfo, format: Format, duration: Duration) -> io: } } +#[cfg(unix)] async fn socket_loop( socket_path: impl AsRef, info: SessionInfo, diff --git a/src/subcommand/listen.rs b/src/subcommand/listen.rs index 0a44697..9b4f3b4 100644 --- a/src/subcommand/listen.rs +++ b/src/subcommand/listen.rs @@ -6,7 +6,6 @@ use derive_more::{Display, Error, From}; use distant_core::{ DistantServer, DistantServerOptions, SecretKey32, UnprotectedToHexKey, XChaCha20Poly1305Codec, }; -use fork::{daemon, Fork}; use log::*; use tokio::{io, task::JoinError}; @@ -31,20 +30,7 @@ impl ExitCodeError for Error { pub fn run(cmd: ListenSubcommand, opt: CommonOpt) -> Result<(), Error> { if cmd.daemon { - // NOTE: We keep the stdin, stdout, stderr open so we can print out the pid with the parent - match daemon(false, true) { - Ok(Fork::Child) => { - let rt = tokio::runtime::Runtime::new()?; - rt.block_on(async { run_async(cmd, opt, true).await })?; - } - Ok(Fork::Parent(pid)) => { - info!("[distant detached, pid = {}]", pid); - if fork::close_fd().is_err() { - return Err(Error::Fork); - } - } - Err(_) => return Err(Error::Fork), - } + run_daemon(cmd, opt)?; } else { let rt = tokio::runtime::Runtime::new()?; rt.block_on(async { run_async(cmd, opt, false).await })?; @@ -53,6 +39,45 @@ pub fn run(cmd: ListenSubcommand, opt: CommonOpt) -> Result<(), Error> { Ok(()) } +#[cfg(windows)] +fn run_daemon(_cmd: ListenSubcommand, _opt: CommonOpt) -> Result<(), Error> { + use std::process::{Command, Stdio}; + let mut args = std::env::args_os().filter(|arg| arg != "--daemon"); + let program = args.next().ok_or(Error::Fork)?; + + let child = Command::new(program) + .args(args) + .stdin(Stdio::null()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .spawn()?; + info!("[distant detached, pid = {}]", child.id()); + Ok(()) +} + +#[cfg(unix)] +fn run_daemon(cmd: ListenSubcommand, opt: CommonOpt) -> Result<(), Error> { + use fork::{daemon, Fork}; + + // NOTE: We keep the stdin, stdout, stderr open so we can print out the pid with the parent + match daemon(false, true) { + Ok(Fork::Child) => { + let rt = tokio::runtime::Runtime::new()?; + rt.block_on(async { run_async(cmd, opt, true).await })?; + Ok(()) + } + Ok(Fork::Parent(pid)) => { + info!("[distant detached, pid = {}]", pid); + if fork::close_fd().is_err() { + Err(Error::Fork) + } else { + Ok(()) + } + } + Err(_) => Err(Error::Fork), + } +} + async fn run_async(cmd: ListenSubcommand, _opt: CommonOpt, is_forked: bool) -> Result<(), Error> { let addr = cmd.host.to_ip_addr(cmd.use_ipv6)?; let shutdown_after = cmd.to_shutdown_after_duration(); @@ -83,6 +108,7 @@ async fn run_async(cmd: ListenSubcommand, _opt: CommonOpt, is_forked: bool) -> R println!("DISTANT DATA -- {} {}", port, key_hex_string); // For the child, we want to fully disconnect it from pipes, which we do now + #[cfg(unix)] if is_forked && fork::close_fd().is_err() { return Err(Error::Fork); } diff --git a/src/subcommand/mod.rs b/src/subcommand/mod.rs index 2b221df..b4fbade 100644 --- a/src/subcommand/mod.rs +++ b/src/subcommand/mod.rs @@ -51,7 +51,7 @@ impl CommandRunner { use distant_ssh2::{Ssh2Session, Ssh2SessionOpts}; let SshConnectionOpts { host, port, user } = ssh_connection; - let session = Ssh2Session::connect( + let mut session = Ssh2Session::connect( host, Ssh2SessionOpts { port: Some(port), @@ -59,12 +59,14 @@ impl CommandRunner { ..Default::default() }, ) - .map_err(wrap_err)? - .authenticate(Default::default()) - .await .map_err(wrap_err)?; - (session, None) + session + .authenticate(Default::default()) + .await + .map_err(wrap_err)?; + + (session.into_ssh_client_session().map_err(wrap_err)?, None) } Method::Distant => { @@ -82,6 +84,7 @@ impl CommandRunner { .map_err(wrap_err)?; (session, lsp_data) } + #[cfg(unix)] SessionParams::Socket { path, codec } => { let session = Session::unix_connect_timeout(path, codec, timeout) .await @@ -102,10 +105,8 @@ enum SessionParams { codec: XChaCha20Poly1305Codec, lsp_data: Option, }, - Socket { - path: PathBuf, - codec: PlainCodec, - }, + #[cfg(unix)] + Socket { path: PathBuf, codec: PlainCodec }, } async fn retrieve_session_params(