mirror of
https://github.com/veeso/termscp.git
synced 2026-09-18 10:06:13 -07:00
Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 99b76f2fd9 | |||
| a57f020e4a | |||
| da23372d4b | |||
| 5304bc3224 | |||
| 6de1cdb39c | |||
| 57a710eff5 | |||
| 6e6d077532 | |||
| 63f7f0d084 | |||
| 9d2c3746d0 | |||
| 0b787fee34 | |||
| 48d3701678 | |||
| 622a64ac58 | |||
| 751f68f6d4 | |||
| e966a83220 | |||
| d99c76b43c | |||
| 7434c51063 | |||
| 9fbd617f88 | |||
| 2bbe560ec5 | |||
| 7472ca98a0 | |||
| 974b6d6917 | |||
| 98a1ce42dc | |||
| afbc74113f | |||
| 08c51a32cc | |||
| 739517f7e4 | |||
| 77281ed926 | |||
| 4b6325ebe3 | |||
| 6933a98cda |
Executable
+44
@@ -0,0 +1,44 @@
|
||||
#!/usr/bin/env bash
|
||||
#
|
||||
# termscp pre-commit hook.
|
||||
#
|
||||
# Runs two gates before a commit is recorded:
|
||||
# 1. dprint -- check formatting (Markdown, TOML, YAML, Rust)
|
||||
# 2. cargo-deny -- advisories, licenses, bans, and sources
|
||||
#
|
||||
# Install with `just setup_githooks` (sets core.hooksPath to .githooks).
|
||||
# Bypass in an emergency with `git commit --no-verify`.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
fail() {
|
||||
echo "pre-commit: $1" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
command -v just >/dev/null 2>&1 || fail "just not found; install it to run the pre-commit checks"
|
||||
|
||||
if ! git diff --cached --quiet --diff-filter=ACMR --; then
|
||||
# Check the exact tree that will be committed, not possibly different
|
||||
# working-tree contents. The trailing slash is required by checkout-index.
|
||||
index_tree="$(mktemp -d)"
|
||||
cleanup() {
|
||||
rm -rf -- "$index_tree"
|
||||
}
|
||||
trap cleanup EXIT
|
||||
git checkout-index --all --prefix="$index_tree/"
|
||||
|
||||
echo "pre-commit: checking staged-tree formatting"
|
||||
(
|
||||
cd "$index_tree"
|
||||
just fmt_check
|
||||
)
|
||||
|
||||
echo "pre-commit: checking staged-tree dependencies"
|
||||
(
|
||||
cd "$index_tree"
|
||||
just deny
|
||||
)
|
||||
fi
|
||||
|
||||
echo "pre-commit: all checks passed"
|
||||
@@ -4,7 +4,6 @@ about: Create a report of the bug you've encountered
|
||||
title: "[BUG] - ISSUE_TITLE"
|
||||
labels: bug
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,7 +4,6 @@ about: Report a typo/error in a repository document
|
||||
title: "[COPY] - ISSUE_TITLE"
|
||||
labels: documentation
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
## Report
|
||||
|
||||
@@ -4,7 +4,6 @@ about: Suggest an idea to improve termscp
|
||||
title: "[Feature Request] - FEATURE_TITLE"
|
||||
labels: "new feature"
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -4,5 +4,4 @@ about: Ask what you want about the project
|
||||
title: "[QUESTION] - TITLE"
|
||||
labels: question
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
@@ -4,7 +4,6 @@ about: Create a report of a security vulnerability
|
||||
title: "[SECURITY] - ISSUE_TITLE"
|
||||
labels: security
|
||||
assignees: veeso
|
||||
|
||||
---
|
||||
|
||||
## Description
|
||||
|
||||
@@ -1,40 +1,15 @@
|
||||
# ISSUE _NUMBER_ - PULL_REQUEST_TITLE
|
||||
# Description
|
||||
|
||||
Fixes # (issue)
|
||||
|
||||
## Description
|
||||
|
||||
Please include a summary of the change and which issue is fixed. Please also include relevant motivation and context. List any dependencies that are required for this change.
|
||||
|
||||
List here your changes
|
||||
|
||||
- I made this...
|
||||
- I made also that...
|
||||
|
||||
## Type of change
|
||||
|
||||
Please select relevant options.
|
||||
|
||||
- [ ] Bug fix (non-breaking change which fixes an issue)
|
||||
- [ ] New feature (non-breaking change which adds functionality)
|
||||
- [ ] Breaking change (fix or feature that would cause existing functionality to not work as expected)
|
||||
- [ ] This change requires a documentation update
|
||||
<!--
|
||||
Provide a brief description of the changes you made in this pull request. If your changes are related to a specific issue, please mention the issue number (e.g., "Fixes #123").
|
||||
-->
|
||||
|
||||
## Checklist
|
||||
|
||||
- [ ] My code follows the contribution guidelines of this project
|
||||
- [ ] I have performed a self-review of my own code
|
||||
- [ ] I have commented my code, particularly in hard-to-understand areas
|
||||
- [ ] My changes generate no new warnings
|
||||
- [ ] I formatted the code with `cargo fmt`
|
||||
- [ ] I checked my code using `cargo clippy` and reports no warnings
|
||||
- [ ] I have added tests that prove my fix is effective or that my feature works
|
||||
- [ ] I have introduced no new *C-bindings*
|
||||
- [ ] The changes I've made are Windows, MacOS, UNIX, Linux compatible (or I've handled them using `cfg target_os`)
|
||||
- [ ] I increased or maintained the code coverage for the project, compared to the previous commit
|
||||
- [ ] I have read the [AI Policy](https://github.com/veeso/termscp/blob/main/AI_POLICY.md) and the [contributing guidelines](https://github.com/veeso/termscp/blob/main/CONTRIBUTING.md).
|
||||
|
||||
## Acceptance tests
|
||||
## AI Disclosure
|
||||
|
||||
wait for a *project maintainer* to fulfill this section...
|
||||
|
||||
- [ ] regression test: ...
|
||||
<!--
|
||||
Describe how you used AI tools in this contribution. If you did not use any AI tools, write "N/A".
|
||||
-->
|
||||
|
||||
+97
-27
@@ -5,12 +5,12 @@ on:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- "./site/**/*"
|
||||
- "site/**"
|
||||
push:
|
||||
branches: [main]
|
||||
paths-ignore:
|
||||
- "*.md"
|
||||
- "./site/**/*"
|
||||
- "site/**"
|
||||
|
||||
env:
|
||||
CARGO_TERM_COLOR: always
|
||||
@@ -19,50 +19,120 @@ permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-(${{ matrix.os }})
|
||||
toolchain:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
channel: ${{ steps.extract.outputs.result }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Extract toolchain channel from rust-toolchain.toml
|
||||
id: extract
|
||||
uses: mikefarah/yq@c14f446382944492701b16c1ddb48bb9dbe683e3 # v4.53.6
|
||||
with:
|
||||
cmd: yq '.toolchain.channel' rust-toolchain.toml
|
||||
|
||||
fmt:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Rust (nightly)
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
- name: Check formatting
|
||||
uses: dprint/check@9cb3a2b17a8e606d37aae341e49df3654933fc23 # v2.3
|
||||
|
||||
install-scripts:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Check install scripts
|
||||
run: just check_install_scripts
|
||||
|
||||
crates:
|
||||
needs: toolchain
|
||||
name: crates-${{ matrix.os }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Linux dependencies
|
||||
if: runner.os == 'Linux'
|
||||
run: sudo apt update && sudo apt install -y libdbus-1-dev libsmbclient-dev
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libsmbclient-dev
|
||||
- name: Install macOS dependencies
|
||||
if: runner.os == 'macOS'
|
||||
run: |
|
||||
brew update
|
||||
brew install \
|
||||
pkg-config \
|
||||
samba
|
||||
pkg-config \
|
||||
samba
|
||||
brew link --force samba
|
||||
- name: Install nightly toolchain
|
||||
if: runner.os == 'Linux'
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: nightly
|
||||
components: rustfmt
|
||||
- name: Format
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo +nightly fmt --all -- --check
|
||||
- name: Install stable toolchain
|
||||
uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
toolchain: stable
|
||||
toolchain: ${{ needs.toolchain.outputs.channel }}
|
||||
components: clippy
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Build
|
||||
if: runner.os != 'Linux'
|
||||
run: cargo build
|
||||
- name: Run tests (Linux)
|
||||
run: just build_crates
|
||||
- name: Test (Linux)
|
||||
if: runner.os == 'Linux'
|
||||
run: cargo test --no-default-features --features github-actions --no-fail-fast
|
||||
- name: Run tests
|
||||
run: just test "--no-default-features --features github-actions --no-fail-fast"
|
||||
- name: Test
|
||||
if: runner.os != 'Linux'
|
||||
run: cargo test --verbose --features github-actions
|
||||
run: just test "--verbose --features github-actions"
|
||||
- name: Clippy
|
||||
run: cargo clippy -- -Dwarnings
|
||||
run: just clippy "-- -D warnings"
|
||||
|
||||
doc:
|
||||
needs: toolchain
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Linux dependencies
|
||||
run: sudo apt-get update && sudo apt-get install -y libdbus-1-dev libsmbclient-dev
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: ${{ needs.toolchain.outputs.channel }}
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Build documentation
|
||||
run: just doc
|
||||
|
||||
deny:
|
||||
needs: toolchain
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install Rust
|
||||
uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable
|
||||
with:
|
||||
toolchain: ${{ needs.toolchain.outputs.channel }}
|
||||
- name: Install cargo-deny
|
||||
uses: taiki-e/install-action@37f7c5781271959fb65b6b35224e28652ff2b63d # v2.87.0
|
||||
with:
|
||||
tool: cargo-deny
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Check dependencies
|
||||
run: just deny
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
name: codeberg-mirror
|
||||
on:
|
||||
push:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
mirror:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
with:
|
||||
fetch-depth: 0
|
||||
persist-credentials: false
|
||||
- name: "Mirror to Codeberg"
|
||||
uses: yesolutions/mirror-action@1708f16cdb28634fd3ba10c5c79abc91f5578a14 # v0.7.0
|
||||
with:
|
||||
REMOTE: 'ssh://git@codeberg.org/veeso/termscp.git'
|
||||
GIT_SSH_PRIVATE_KEY: ${{ secrets.GIT_SSH_PRIVATE_KEY }}
|
||||
GIT_SSH_NO_VERIFY_HOST: "true"
|
||||
@@ -22,7 +22,7 @@ jobs:
|
||||
runs-on: ${{ matrix.os }}
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install termscp from script
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
name: Linux musl probe
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- "test/440-musl-release"
|
||||
paths:
|
||||
- ".github/workflows/musl.yml"
|
||||
- "Cargo.lock"
|
||||
- "Cargo.toml"
|
||||
- "src/**"
|
||||
workflow_dispatch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
build:
|
||||
name: build-${{ matrix.target }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-musl
|
||||
runner: ubuntu-24.04
|
||||
- target: aarch64-unknown-linux-musl
|
||||
runner: ubuntu-24.04-arm
|
||||
runs-on: ${{ matrix.runner }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
|
||||
- name: Build and audit static binary
|
||||
env:
|
||||
TARGET: ${{ matrix.target }}
|
||||
run: |
|
||||
docker run --rm \
|
||||
--env TARGET \
|
||||
--volume "$GITHUB_WORKSPACE:/work" \
|
||||
--workdir /work \
|
||||
rust:1.98-alpine3.22 \
|
||||
sh -euxc '
|
||||
apk add --no-cache \
|
||||
bison \
|
||||
binutils \
|
||||
build-base \
|
||||
file \
|
||||
flex \
|
||||
git \
|
||||
gnutls-dev \
|
||||
libgit2-dev \
|
||||
libgit2-static \
|
||||
libunistring-dev \
|
||||
libunistring-static \
|
||||
linux-headers \
|
||||
openssl-dev \
|
||||
openssl-libs-static \
|
||||
perl \
|
||||
perl-parse-yapp \
|
||||
pkgconf \
|
||||
python3 \
|
||||
wget \
|
||||
xz \
|
||||
zlib-dev \
|
||||
zlib-static
|
||||
rustup target add "$TARGET"
|
||||
cargo fetch --locked
|
||||
NATIVE_CFLAGS="-O2 -fPIC"
|
||||
if [ "$TARGET" = "aarch64-unknown-linux-musl" ]; then
|
||||
NATIVE_CFLAGS="$NATIVE_CFLAGS -mno-outline-atomics"
|
||||
fi
|
||||
export CFLAGS="$NATIVE_CFLAGS"
|
||||
|
||||
mkdir -p /tmp/native
|
||||
wget -q https://ftp.gnu.org/gnu/nettle/nettle-3.10.1.tar.gz \
|
||||
-O /tmp/native/nettle.tar.gz
|
||||
tar -xzf /tmp/native/nettle.tar.gz -C /tmp/native
|
||||
cd /tmp/native/nettle-3.10.1
|
||||
./configure \
|
||||
--prefix=/tmp/native/nettle \
|
||||
--disable-shared \
|
||||
--enable-static \
|
||||
--disable-documentation \
|
||||
--enable-mini-gmp
|
||||
make -j$(getconf _NPROCESSORS_ONLN)
|
||||
make install
|
||||
|
||||
wget -q https://www.gnupg.org/ftp/gcrypt/gnutls/v3.8/gnutls-3.8.13.tar.xz \
|
||||
-O /tmp/native/gnutls.tar.xz
|
||||
tar -xf /tmp/native/gnutls.tar.xz -C /tmp/native
|
||||
cd /tmp/native/gnutls-3.8.13
|
||||
PKG_CONFIG_PATH=/tmp/native/nettle/lib/pkgconfig \
|
||||
./configure \
|
||||
--prefix=/tmp/native/gnutls \
|
||||
--disable-shared \
|
||||
--enable-static \
|
||||
--disable-doc \
|
||||
--disable-tests \
|
||||
--disable-nls \
|
||||
--disable-hardware-acceleration \
|
||||
--with-nettle-mini \
|
||||
--with-included-libtasn1 \
|
||||
--with-included-unistring \
|
||||
--without-idn \
|
||||
--without-p11-kit \
|
||||
--without-brotli \
|
||||
--without-zstd \
|
||||
--without-zlib
|
||||
make -j$(getconf _NPROCESSORS_ONLN)
|
||||
make install
|
||||
|
||||
mkdir -p /tmp/native/pkgconfig
|
||||
sed \
|
||||
-e "s#^Libs:.*#Libs: -L/tmp/native/gnutls/lib -lgnutls -latomic -L/tmp/native/nettle/lib -lhogweed -lnettle#" \
|
||||
-e "/^Requires.private:/d" \
|
||||
-e "s#^Cflags:.*#Cflags: -I/tmp/native/gnutls/include -I/tmp/native/nettle/include#" \
|
||||
/tmp/native/gnutls/lib/pkgconfig/gnutls.pc \
|
||||
> /tmp/native/pkgconfig/gnutls.pc
|
||||
|
||||
cd /work
|
||||
PAVAO_SRC=$(find "${CARGO_HOME:-/usr/local/cargo}/registry/src" \
|
||||
-type d -name "pavao-src-4.24.6" -print -quit)
|
||||
test -n "$PAVAO_SRC"
|
||||
cp -R "$PAVAO_SRC" /tmp/pavao-src
|
||||
perl -0pi -e "s#( \\\"lib/replace/replace\\.c\\\",\\n)#\$1 \\\"lib/replace/closefrom.c\\\",\\n \\\"lib/replace/strptime.c\\\",\\n#" \
|
||||
/tmp/pavao-src/src/lib.rs
|
||||
cat >> Cargo.toml <<EOF
|
||||
|
||||
[patch.crates-io]
|
||||
pavao-src = { path = "/tmp/pavao-src" }
|
||||
EOF
|
||||
cargo update -p pavao-src@4.24.6
|
||||
export PKG_CONFIG_ALL_STATIC=1
|
||||
export PKG_CONFIG_PATH=/tmp/native/pkgconfig:/tmp/native/nettle/lib/pkgconfig:/usr/lib/pkgconfig
|
||||
export RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-static"
|
||||
cargo build --locked --release --target "$TARGET" \
|
||||
--features smb-vendored
|
||||
file "target/$TARGET/release/termscp"
|
||||
readelf -l "target/$TARGET/release/termscp" | \
|
||||
tee /tmp/program-headers.txt
|
||||
readelf -d "target/$TARGET/release/termscp" | \
|
||||
tee /tmp/dynamic-section.txt
|
||||
! grep -q INTERP /tmp/program-headers.txt
|
||||
! grep -q NEEDED /tmp/dynamic-section.txt
|
||||
'
|
||||
|
||||
- name: Upload verified binary
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: ${{ matrix.target }}
|
||||
path: target/${{ matrix.target }}/release/termscp
|
||||
if-no-files-found: error
|
||||
retention-days: 7
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
name: github-pages
|
||||
url: ${{ steps.deployment.outputs.page_url }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- name: Install mdBook
|
||||
@@ -58,9 +58,9 @@ jobs:
|
||||
<a href="./en-US/">termscp documentation</a>
|
||||
HTML
|
||||
- name: Upload artifact
|
||||
uses: actions/upload-pages-artifact@56afc609e74202658d3ffba0e8f6dda462b719fa # v3
|
||||
uses: actions/upload-pages-artifact@fc324d3547104276b827a68afc52ff2a11cc49c9 # v5.0.0
|
||||
with:
|
||||
path: site_out
|
||||
- name: Deploy to GitHub Pages
|
||||
id: deployment
|
||||
uses: actions/deploy-pages@d6db90164ac5ed86f2b6aed7e0febac5b3c0c03e # v4
|
||||
uses: actions/deploy-pages@cd2ce8fcbc39b97be8ca5fce6e763baed58fa128 # v5.0.0
|
||||
|
||||
@@ -22,23 +22,35 @@ jobs:
|
||||
outputs:
|
||||
version: ${{ inputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
persist-credentials: true
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
|
||||
- name: Validate version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "invalid release version: $VERSION (expected MAJOR.MINOR.PATCH)" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
- name: Configure git identity
|
||||
run: |
|
||||
git config user.name "veeso"
|
||||
git config user.email "christian.visintin@veeso.dev"
|
||||
|
||||
- name: Install git-cliff
|
||||
uses: taiki-e/install-action@56545b37b57562edd73171cb6c62cc509db4c34e # v2
|
||||
uses: taiki-e/install-action@37f7c5781271959fb65b6b35224e28652ff2b63d # v2.87.0
|
||||
with:
|
||||
tool: git-cliff
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
|
||||
- name: Bump version
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
@@ -47,7 +59,7 @@ jobs:
|
||||
- name: Generate CHANGELOG
|
||||
env:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: git-cliff --tag "v$VERSION" -o CHANGELOG.md
|
||||
run: just changelog "$VERSION"
|
||||
|
||||
- name: Generate release notes
|
||||
env:
|
||||
@@ -55,7 +67,7 @@ jobs:
|
||||
run: git-cliff --unreleased --tag "v$VERSION" --strip header -o RELEASE_NOTES.md
|
||||
|
||||
- name: Upload release notes
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-notes
|
||||
path: RELEASE_NOTES.md
|
||||
@@ -111,15 +123,20 @@ jobs:
|
||||
TARGET: ${{ matrix.target }}
|
||||
FEATURES: ${{ matrix.features }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.dry_run && github.sha || 'main' }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
with:
|
||||
targets: ${{ matrix.target }}
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Install Rust target
|
||||
if: matrix.kind != 'windows'
|
||||
run: rustup target add "$TARGET"
|
||||
- name: Install Rust target
|
||||
if: matrix.kind == 'windows'
|
||||
run: rustup target add "$env:TARGET"
|
||||
|
||||
# ---- Linux: native per-arch build (x86_64 on ubuntu-latest, aarch64 on ubuntu-24.04-arm) ----
|
||||
- name: Install dependencies (Linux)
|
||||
@@ -156,10 +173,10 @@ jobs:
|
||||
cargo install cargo-deb
|
||||
- name: Build (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: cargo build --release --features smb-vendored --target "$TARGET"
|
||||
run: just build_release "$TARGET" "--features smb-vendored"
|
||||
- name: Build deb (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: cargo deb --no-build --target "$TARGET" --features smb-vendored
|
||||
run: just package_deb "$TARGET"
|
||||
|
||||
# ---- macOS ----
|
||||
- name: Install deps (macOS)
|
||||
@@ -173,12 +190,12 @@ jobs:
|
||||
cpanm Parse::Yapp::Driver
|
||||
- name: Build (macOS)
|
||||
if: matrix.kind == 'macos'
|
||||
run: cargo build --release $FEATURES --target "$TARGET"
|
||||
run: just build_release "$TARGET" "$FEATURES"
|
||||
|
||||
# ---- Windows ----
|
||||
- name: Build (Windows)
|
||||
if: matrix.kind == 'windows'
|
||||
run: cargo build --release --features smb-vendored --target "$env:TARGET"
|
||||
run: just build_release "$env:TARGET" "--features smb-vendored"
|
||||
|
||||
# ---- Package posix (tar.gz) ----
|
||||
- name: Package (posix)
|
||||
@@ -204,7 +221,7 @@ jobs:
|
||||
run: cp target/"$TARGET"/debian/*.deb artifact/
|
||||
|
||||
- name: Upload build artifacts
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: build-${{ matrix.target }}
|
||||
path: artifact/*
|
||||
@@ -218,14 +235,14 @@ jobs:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: build-*
|
||||
path: dl
|
||||
merge-multiple: true
|
||||
|
||||
- name: Checkout homebrew tap
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
repository: veeso/homebrew-termscp
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
@@ -243,7 +260,7 @@ jobs:
|
||||
BASE="https://github.com/veeso/termscp/releases/latest/download"
|
||||
cat > tap/Formula/termscp.rb <<EOF
|
||||
class Termscp < Formula
|
||||
desc "A feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/S3/Kube/SMB/WebDAV"
|
||||
desc "A feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/S3/GCS/Kube/SMB/WebDAV"
|
||||
homepage "https://termscp.rs/"
|
||||
license "MIT"
|
||||
version "$VERSION"
|
||||
@@ -316,21 +333,21 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
ref: ${{ inputs.dry_run && github.sha || 'main' }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
pattern: build-*
|
||||
path: dl
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download release notes
|
||||
uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
name: release-notes
|
||||
path: notes
|
||||
@@ -355,7 +372,7 @@ jobs:
|
||||
|
||||
- name: Upload assets artifact (dry run)
|
||||
if: ${{ inputs.dry_run }}
|
||||
uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
name: release-assets-dryrun
|
||||
path: out/*
|
||||
@@ -380,13 +397,14 @@ jobs:
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- uses: dtolnay/rust-toolchain@29eef336d9b2848a0b548edc03f92a220660cdb8 # stable
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
|
||||
- name: Install dependencies (Linux)
|
||||
run: |
|
||||
@@ -421,12 +439,12 @@ jobs:
|
||||
|
||||
- name: Authenticate to crates.io
|
||||
id: auth
|
||||
uses: rust-lang/crates-io-auth-action@bbd81622f20ce9e2dd9622e3218b975523e45bbe # v1.0.4
|
||||
uses: rust-lang/crates-io-auth-action@c6f97d42243bad5fab37ca0427f495c86d5b1a18 # v1.0.5
|
||||
|
||||
- name: Publish to crates.io
|
||||
env:
|
||||
CARGO_REGISTRY_TOKEN: ${{ steps.auth.outputs.token }}
|
||||
run: cargo publish --features smb-vendored
|
||||
run: just publish_crate
|
||||
|
||||
publish-choco:
|
||||
needs: [prepare, release]
|
||||
|
||||
@@ -9,30 +9,28 @@ on:
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
defaults:
|
||||
run:
|
||||
working-directory: site
|
||||
|
||||
jobs:
|
||||
build-site:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
- uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4.4.0
|
||||
- uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm
|
||||
cache-dependency-path: site/package-lock.json
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
run: just site_install
|
||||
- name: Format
|
||||
run: npm run format:check
|
||||
run: just site_fmt_check
|
||||
- name: Lint
|
||||
run: npm run check
|
||||
run: just site_check
|
||||
- name: Test
|
||||
run: npm test --if-present
|
||||
run: just site_test
|
||||
- name: Build
|
||||
run: npm run build
|
||||
run: just site_build
|
||||
|
||||
@@ -13,7 +13,7 @@ jobs:
|
||||
issues: write
|
||||
pull-requests: write
|
||||
steps:
|
||||
- uses: actions/stale@a20b814fb01b71def3bd6f56e7494d667ddf28da # v4.1.1
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
days-before-issue-stale: 30
|
||||
days-before-issue-close: 7
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
# AI Policy
|
||||
|
||||
The maintainers of this project don't want to interact directly with bots.
|
||||
Contributions must come from a human who understands the submitted code or text,
|
||||
can explain it, and is responsible for its quality as if they wrote it
|
||||
themselves. Using AI as a tool to help you write or reason about a contribution
|
||||
is fine; submitting AI output you don't understand, or relaying messages
|
||||
between a maintainer and an AI, is not.
|
||||
|
||||
If you wish to include context from an interaction with AI in your comments, it
|
||||
must be in a quote block (e.g., using `>`) and disclosed as such. It must be
|
||||
accompanied by human commentary explaining the relevance and implications of
|
||||
the context.
|
||||
|
||||
If you plan to contribute to termscp using AI tools, you **must** read,
|
||||
understand and follow this policy.
|
||||
|
||||
## Disclosure
|
||||
|
||||
You **must** disclose the use of AI tools in your contributions. You
|
||||
**must** perform this disclosure through the pull request template; filing
|
||||
PRs without using the pull request template is a violation of this policy.
|
||||
|
||||
This applies to any AI tool that generated or substantially shaped the content
|
||||
you submit — code, prose or commit messages — including agents, chat assistants
|
||||
and AI-assisted editors. Mechanical editor autocompletion of a few characters
|
||||
does not need to be disclosed; if in doubt, disclose.
|
||||
|
||||
## Translations
|
||||
|
||||
AI is useful when communicating as a non-native English speaker. If you are
|
||||
using AI to edit your comments for this purpose, please take the time to ensure
|
||||
it reflects your own voice and ideas.
|
||||
|
||||
## Provenance and licensing
|
||||
|
||||
You are responsible for the provenance of anything you submit. AI tools can
|
||||
reproduce code or text from their training data verbatim, which may carry a
|
||||
license incompatible with this project. By contributing, you confirm the work
|
||||
is yours to submit under the project's license and does not infringe a third
|
||||
party's rights. The same standard that applies to code you write by hand.
|
||||
|
||||
## Consequences
|
||||
|
||||
Violating this policy may get your contribution rejected and, for repeated or
|
||||
egregious violations, get you blocked from the project without further warning.
|
||||
|
||||
## Examples
|
||||
|
||||
Examples of things that are not allowed:
|
||||
|
||||
- Point an agent to a GitHub issue, ask it to solve the issue and open a PR,
|
||||
without understanding the issue, the PR, or testing the solution yourself.
|
||||
- Copy responses from the AI when replying to questions from maintainers,
|
||||
without understanding the question or the response.
|
||||
In other words, you should not "play telephone" between your AI-generated code,
|
||||
the reviewer, and the AI tool.
|
||||
|
||||
## Inspiration
|
||||
|
||||
This AI policy was inspired by:
|
||||
|
||||
- [release-plz](https://github.com/release-plz/.github/blob/main/AI_POLICY.md)
|
||||
+66
-34
@@ -1,3 +1,5 @@
|
||||
# Changelog
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Released on 2026-06-08
|
||||
@@ -10,6 +12,7 @@ Released on 2026-06-08
|
||||
> crates.io versions are immutable (no overwrite) and Chocolatey's
|
||||
> moderation queue blocks a fast re-push, so a clean 1.1.1 without vergen
|
||||
> is the only fix.
|
||||
|
||||
## 1.1.0
|
||||
|
||||
Released on 2026-06-08
|
||||
@@ -29,7 +32,7 @@ Released on 2026-06-08
|
||||
- **install:** add Windows PowerShell installer and copy buttons on site
|
||||
> Add install.ps1 mirroring install.sh for Windows: arch detection,
|
||||
> release zip download, binary extraction, user PATH update.
|
||||
>
|
||||
>
|
||||
> - copy install.ps1 to site public/ at build time (copy-install.mjs)
|
||||
> - serve /install.ps1 with text/plain Content-Type (vercel.json)
|
||||
> - add PowerShell one-liner to install page and README
|
||||
@@ -38,11 +41,11 @@ Released on 2026-06-08
|
||||
- **config:** move config dir to ~/.config/termscp on macOS and %USERPROFILE%\.termscp on Windows
|
||||
> Resolve the config directory through a single per-platform config_dir()
|
||||
> function instead of relying on dirs::config_dir everywhere:
|
||||
>
|
||||
>
|
||||
> - macOS: ~/.config/termscp (was ~/Library/Application Support/termscp)
|
||||
> - Windows: %USERPROFILE%\.termscp (was roaming %APPDATA%\termscp)
|
||||
> - Linux/other: /termscp (unchanged)
|
||||
>
|
||||
>
|
||||
> Existing users are migrated automatically on first run: when the new
|
||||
> directory is absent and the legacy location exists, the whole config
|
||||
> directory is moved to the new path. The cache directory stays at the
|
||||
@@ -54,7 +57,7 @@ Released on 2026-06-08
|
||||
> Single workflow_dispatch (version, dry_run) that bumps versions, regenerates
|
||||
> CHANGELOG via git-cliff, rebuilds site CSS, builds all targets, creates the
|
||||
> GitHub release, updates the Homebrew tap and publishes Chocolatey.
|
||||
>
|
||||
>
|
||||
> - dist/release/bump_version.sh: version replacer across all tracked locations (+tests)
|
||||
> - .github/workflows/release.yml: prepare -> build matrix -> homebrew/release -> choco
|
||||
> - retire build-artifacts.yml (merged into release.yml)
|
||||
@@ -66,7 +69,7 @@ Released on 2026-06-08
|
||||
- fix release notes generation in release workflow
|
||||
> prepare job failed: git-cliff --latest crashed with 'trim_start_matches on
|
||||
> null' because the checkout was shallow (no tags/history) so no release existed.
|
||||
>
|
||||
>
|
||||
> - checkout prepare with fetch-depth: 0 + fetch-tags so git-cliff sees full
|
||||
> history and tags (also fixes an otherwise-truncated CHANGELOG)
|
||||
> - generate release notes with --unreleased --tag v$VERSION instead of --latest:
|
||||
@@ -143,7 +146,7 @@ Released on 2026-06-08
|
||||
> Migrate the transfer progress UI to tuirealm 4, where the stdlib
|
||||
> `ProgressBar` widget was dropped, by rebuilding the dual-bar panel on
|
||||
> top of `Gauge`.
|
||||
>
|
||||
>
|
||||
> - Restore the unified two-bar look: the full bar (top) and partial bar
|
||||
> (bottom) draw joined borders so they read as a single panel; a single
|
||||
> file shows one fully-bordered bar.
|
||||
@@ -159,19 +162,19 @@ Released on 2026-06-08
|
||||
> its top border is dropped to join the seam with the full bar. That left
|
||||
> the partial bar with one inner row while the full bar kept two, so the
|
||||
> two gauges rendered at unequal heights.
|
||||
>
|
||||
>
|
||||
> - Move the filename from the partial bar's title into its gauge label.
|
||||
> - Skip setting an empty title so no phantom top-positioned title triggers
|
||||
> the inset.
|
||||
> - Put the panel title on the top (full) bar for multi-file transfers.
|
||||
> - Bump the two-bar popup height to fit the joined panel.
|
||||
>
|
||||
>
|
||||
> Also bump Cargo.lock and adapt the embedded terminal to the new vt100
|
||||
> `screen_mut()` API.
|
||||
- **copy:** prevent emptying file when copy destination is empty (#421)
|
||||
> An empty copy destination resolved to the source file's own path, so
|
||||
> std::fs::copy truncated the original file to 0 bytes.
|
||||
>
|
||||
>
|
||||
> - localhost::copy now refuses to copy a file onto itself, returning an
|
||||
> error instead of truncating it (root cause).
|
||||
> - action_copy treats an empty/whitespace destination as a cancel.
|
||||
@@ -180,7 +183,7 @@ Released on 2026-06-08
|
||||
> path. Downstream upload logic treats the queued destination as the full
|
||||
> file path and passes it straight to create_file, so transfers failed
|
||||
> with a Failure error when the remote target resolved to a directory.
|
||||
>
|
||||
>
|
||||
> Append each entry's file name to the destination directory at enqueue
|
||||
> time in both enqueue_file and enqueue_all, matching the single-file
|
||||
> transfer path which already builds the full target path.
|
||||
@@ -191,6 +194,7 @@ Released on 2026-06-08
|
||||
> The old manual dist/build/* scripts and dist/{deb,rpm}.sh are superseded by the
|
||||
> automated release workflow. Add the chocolatey package consumed by release.yml.
|
||||
- **site:** copy install.sh from repo root at build time (single source)
|
||||
|
||||
## 1.0.0
|
||||
|
||||
Released on 2026-04-18
|
||||
@@ -243,6 +247,7 @@ Released on 2026-04-18
|
||||
> `fs_pane_mut()`. This eliminates most `is_local_tab()` branching across
|
||||
> 15+ action files.
|
||||
> Key changes:
|
||||
>
|
||||
> - Add `fs: Box<dyn HostBridge>` to Pane, remove from FileTransferActivity
|
||||
> - Replace per-side method pairs with unified pane-dispatched methods
|
||||
> - Unify navigation (changedir, reload, scan, file_exists, has_file_changed)
|
||||
@@ -250,7 +255,7 @@ Released on 2026-04-18
|
||||
> - Replace assert!/panic!/unreachable! with proper error handling
|
||||
> - Fix typo "filetransfer_activiy" across ~29 files
|
||||
> - Add unit tests for Pane
|
||||
>
|
||||
>
|
||||
> Net result: -473 lines, single code path for most file operations.
|
||||
- replace lazy_static with std::sync::LazyLock
|
||||
- migrate from mod.rs to named module files
|
||||
@@ -278,14 +283,14 @@ Released on 2026-04-18
|
||||
> encryption (authenticated, with random nonces) while keeping a legacy
|
||||
> AES-128-CBC decryption path to transparently handle existing bookmarks.
|
||||
- replace recursive byte-counting with entry-based transfer progress (#395)
|
||||
> * fix: replace recursive byte-counting with entry-based transfer progress
|
||||
>
|
||||
> - fix: replace recursive byte-counting with entry-based transfer progress
|
||||
>
|
||||
> Replace the expensive recursive `get_total_transfer_size` pre-calculation
|
||||
> with a lightweight entry-based counter (`TransferProgress`) for the
|
||||
> overall progress bar. This avoids deep `list_dir` traversals before
|
||||
> transfers begin, which could cause FTP idle-timeout disconnections on
|
||||
> large directory trees.
|
||||
>
|
||||
>
|
||||
> The per-file byte-level progress bar (`ProgressStates`) remains
|
||||
> unchanged. Bytes are still tracked via `TransferStates::add_bytes` for
|
||||
> notification threshold logic.
|
||||
@@ -302,7 +307,7 @@ Released on 2026-04-18
|
||||
- sync browsing when entering a directory from filtered/fuzzy view
|
||||
- stabilize core error handling
|
||||
> Remove production panic and unwrap paths from core modules.
|
||||
>
|
||||
>
|
||||
> Propagate bookmark encryption failures, harden file watcher and temp mapped file handling, and clean up dead code in shared utilities.
|
||||
- normalize localhost relative path checks
|
||||
- use time-based redraw interval instead of progress-delta threshold
|
||||
@@ -341,9 +346,9 @@ Released on 2026-04-18
|
||||
> Upgrade tuirealm (3.x -> 4.0.0), tui-realm-stdlib (3 -> 4), tui-term
|
||||
> (0.2 -> 0.3). Apply all breaking changes from the 4.0 migration guide
|
||||
> across the termscp UI.
|
||||
>
|
||||
>
|
||||
> Key changes:
|
||||
>
|
||||
>
|
||||
> - Root-level re-exports removed; imports moved to module-qualified
|
||||
> paths (`tuirealm::application`, `::component`, `::event`, `::props`,
|
||||
> `::state`, `::subscription`, `::listener`, `::ratatui`). Same for
|
||||
@@ -394,6 +399,7 @@ Released on 2026-04-18
|
||||
### Style
|
||||
|
||||
- linter
|
||||
|
||||
## 0.19.1
|
||||
|
||||
Released on 2025-12-20
|
||||
@@ -407,6 +413,7 @@ Released on 2025-12-20
|
||||
- install.sh deb name
|
||||
- install.sh deb name
|
||||
- Updated dependencies to allow build on NetBSD
|
||||
|
||||
## 0.19.0
|
||||
|
||||
Released on 2025-11-11
|
||||
@@ -414,13 +421,13 @@ Released on 2025-11-11
|
||||
### Added
|
||||
|
||||
- Import bookmarks from ssh config with a CLI command (#364)
|
||||
> * feat: Import bookmarks from ssh config with a CLI command
|
||||
>
|
||||
> - feat: Import bookmarks from ssh config with a CLI command
|
||||
>
|
||||
> Use import-ssh-hosts to import all the possible hosts by the configured ssh config or the default one on your machine
|
||||
- Changed file overwrite behaviour (#366)
|
||||
> Now the user can choose for each file whether to overwrite, skip or overwrite all/skip all.
|
||||
- Added `<CTRL+S>` keybinding to get the total size of selected paths. (#367)
|
||||
> * feat: Added `<CTRL+S>` keybinding to get the total size of selected paths.
|
||||
> - feat: Added `<CTRL+S>` keybinding to get the total size of selected paths.
|
||||
- Merge branch '0.19.0'
|
||||
|
||||
### CI
|
||||
@@ -440,8 +447,8 @@ Released on 2025-11-11
|
||||
- typo in file open error message (#349)
|
||||
- SMB support for MacOS with vendored build of libsmbclient.
|
||||
- Report a message while calculating total size of files to transfer. (#362)
|
||||
> * fix: Report a message while calculating total size of files to transfer.
|
||||
>
|
||||
> - fix: Report a message while calculating total size of files to transfer.
|
||||
>
|
||||
> Currently, in case of huge transfers the app may look frozen while calculating the transfer size. We should at least report to the user we are actually doing something.
|
||||
- Issues with update checks (#363)
|
||||
> Removed error popup message if failed to check for updates.
|
||||
@@ -456,6 +463,7 @@ Released on 2025-11-11
|
||||
- 0.19 deps
|
||||
- remotefs-ssh 0.7.1
|
||||
> This version fixes compatibility with hosts which don't use bash/sh as the default shell.
|
||||
|
||||
## 0.18.0
|
||||
|
||||
Released on 2025-06-10
|
||||
@@ -464,7 +472,7 @@ Released on 2025-06-10
|
||||
|
||||
- **Updated dependencies** and updated the Rust edition to `2024`
|
||||
- Replaced the `Exec` popup with a fully functional terminal emulator (#348)
|
||||
> * feat: Replaced the `Exec` popup with a fully functional terminal emulator
|
||||
> - feat: Replaced the `Exec` popup with a fully functional terminal emulator
|
||||
- 0.18
|
||||
|
||||
### Fixed
|
||||
@@ -475,6 +483,7 @@ Released on 2025-06-10
|
||||
### Style
|
||||
|
||||
- catppuccin themes
|
||||
|
||||
## 0.17.0
|
||||
|
||||
Released on 2025-03-23
|
||||
@@ -523,6 +532,7 @@ Released on 2025-03-23
|
||||
- aws-s3 0.4.2
|
||||
- build docker for x86
|
||||
- so apparently native-tls vendored tries to build openssl on windows, wtf guys?
|
||||
|
||||
## 0.16.1
|
||||
|
||||
Released on 2024-11-12
|
||||
@@ -532,6 +542,7 @@ Released on 2024-11-12
|
||||
- cfg unix forbidden in rust .82
|
||||
- gg rust 1.82 for introducing a nice breaking change in config which was not mentioned in changelog
|
||||
- 0.16.1
|
||||
|
||||
## 0.16.0
|
||||
|
||||
Released on 2024-10-14
|
||||
@@ -550,6 +561,7 @@ Released on 2024-10-14
|
||||
- issue 292 New version alert was not displayed due to a semver regex issue. (#300)
|
||||
- 0.16
|
||||
- tiny ui issue
|
||||
|
||||
## 0.15.0
|
||||
|
||||
Released on 2024-10-03
|
||||
@@ -572,14 +584,14 @@ Released on 2024-10-03
|
||||
- issue 277 Fix a bug in the configuration page, which caused being stuck if the added SSH key was empty
|
||||
- popup texts
|
||||
- `isolated-tests` feature to run tests for releasing on distributions which run in isolated environments (#286)
|
||||
> * fix: `isolated-tests` feature to run tests for releasing on distributions which run in isolated environments
|
||||
>
|
||||
> * fix: cond
|
||||
> - fix: `isolated-tests` feature to run tests for releasing on distributions which run in isolated environments
|
||||
> - fix: cond
|
||||
- set date
|
||||
- github ci is stable and reliable (one worker broken each 2 weeks)
|
||||
- ci
|
||||
- readme
|
||||
- include build.rs
|
||||
|
||||
## 0.14.0
|
||||
|
||||
Released on 2024-07-17
|
||||
@@ -608,6 +620,7 @@ Released on 2024-07-17
|
||||
- german manual
|
||||
- removed support for RPM
|
||||
- changelog
|
||||
|
||||
## 0.13.0
|
||||
|
||||
Released on 2024-03-02
|
||||
@@ -624,6 +637,7 @@ Released on 2024-03-02
|
||||
- debian script
|
||||
- debian script
|
||||
- lint???
|
||||
|
||||
## 0.12.2
|
||||
|
||||
Released on 2023-10-01
|
||||
@@ -636,6 +650,7 @@ Released on 2023-10-01
|
||||
|
||||
- fmt
|
||||
- panic if the terminal screen is too small
|
||||
|
||||
## 0.12.1
|
||||
|
||||
Released on 2023-07-06
|
||||
@@ -659,6 +674,7 @@ Released on 2023-07-06
|
||||
- don't run CI on site/.md change
|
||||
- rustup target
|
||||
- don't update path breadcrumb if enter/scan dir failed (#203)
|
||||
|
||||
## 0.12.0
|
||||
|
||||
Released on 2023-05-16
|
||||
@@ -677,6 +693,7 @@ Released on 2023-05-16
|
||||
- pavao 0.2.3
|
||||
- macos script
|
||||
- release date
|
||||
|
||||
## 0.11.3
|
||||
|
||||
Released on 2023-04-19
|
||||
@@ -688,6 +705,7 @@ Released on 2023-04-19
|
||||
### Fixed
|
||||
|
||||
- relative paths windows (#167)
|
||||
|
||||
## 0.11.2
|
||||
|
||||
Released on 2023-04-18
|
||||
@@ -696,6 +714,7 @@ Released on 2023-04-18
|
||||
|
||||
- dependencies up-to-date
|
||||
- site 0.11.2
|
||||
|
||||
## 0.8.1
|
||||
|
||||
Released on 2022-03-22
|
||||
@@ -703,6 +722,7 @@ Released on 2022-03-22
|
||||
### Fixed
|
||||
|
||||
- footer listed "Delete" shortcut as "Make Dir"
|
||||
|
||||
## 0.8.0
|
||||
|
||||
Released on 2022-01-06
|
||||
@@ -710,6 +730,7 @@ Released on 2022-01-06
|
||||
### Arch
|
||||
|
||||
- install rust only if not found on local system
|
||||
|
||||
## 0.7.0
|
||||
|
||||
Released on 2021-10-12
|
||||
@@ -717,6 +738,7 @@ Released on 2021-10-12
|
||||
### Option
|
||||
|
||||
- prompt user when about to replace an existing file caused by a file transfer
|
||||
|
||||
## 0.6.1
|
||||
|
||||
Released on 2021-08-30
|
||||
@@ -724,6 +746,7 @@ Released on 2021-08-30
|
||||
### Fixed
|
||||
|
||||
- When copying files with tricky copy, the upper progress bar shows no text
|
||||
|
||||
## 0.5.1
|
||||
|
||||
Released on 2021-06-21
|
||||
@@ -731,6 +754,7 @@ Released on 2021-06-21
|
||||
### Fix
|
||||
|
||||
- target_family unix means also macos and linux; use BSD target_os
|
||||
|
||||
## 0.5.0
|
||||
|
||||
Released on 2021-05-23
|
||||
@@ -743,6 +767,7 @@ Released on 2021-05-23
|
||||
### Grcov
|
||||
|
||||
- exclude activities
|
||||
|
||||
## 0.4.1
|
||||
|
||||
Released on 2021-04-06
|
||||
@@ -755,18 +780,19 @@ Released on 2021-04-06
|
||||
### Readme
|
||||
|
||||
- one-liner for Homebrew
|
||||
> The one-liner command
|
||||
>
|
||||
> brew install veeso/termscp/termscp
|
||||
>
|
||||
> is equivalent to the two commands
|
||||
>
|
||||
> brew tap veeso/termscp
|
||||
> brew install termscp
|
||||
> The one-liner command
|
||||
>
|
||||
> brew install veeso/termscp/termscp
|
||||
>
|
||||
> is equivalent to the two commands
|
||||
>
|
||||
> brew tap veeso/termscp
|
||||
> brew install termscp
|
||||
|
||||
### SCP
|
||||
|
||||
- fixed symlink not properly detected
|
||||
|
||||
## 0.4.0
|
||||
|
||||
Released on 2021-03-27
|
||||
@@ -786,6 +812,7 @@ Released on 2021-03-27
|
||||
### View
|
||||
|
||||
- return String instead of id
|
||||
|
||||
## 0.3.3
|
||||
|
||||
Released on 2021-02-28
|
||||
@@ -793,6 +820,7 @@ Released on 2021-02-28
|
||||
### Git
|
||||
|
||||
- check for new updates (utils)
|
||||
|
||||
## 0.3.2
|
||||
|
||||
Released on 2021-01-24
|
||||
@@ -800,6 +828,7 @@ Released on 2021-01-24
|
||||
### Testing
|
||||
|
||||
- don't run on windows
|
||||
|
||||
## 0.3.0
|
||||
|
||||
Released on 2021-01-10
|
||||
@@ -834,6 +863,7 @@ Released on 2021-01-10
|
||||
### SetupActivity
|
||||
|
||||
- <CTRL+E> as <DEL>
|
||||
|
||||
## 0.2.0
|
||||
|
||||
Released on 2020-12-21
|
||||
@@ -845,6 +875,7 @@ Released on 2020-12-21
|
||||
### Scp
|
||||
|
||||
- when username was not provided, it didn't fallback to current username
|
||||
|
||||
## 0.1.2
|
||||
|
||||
Released on 2020-12-13
|
||||
@@ -852,6 +883,7 @@ Released on 2020-12-13
|
||||
### FsEntry
|
||||
|
||||
- :*::symlink is now a Option<Box<FsEntry>>; this improved symlinks, which gave errors some times
|
||||
|
||||
## 0.1.0
|
||||
|
||||
Released on 2020-12-06
|
||||
|
||||
@@ -4,36 +4,39 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co
|
||||
|
||||
## Project Overview
|
||||
|
||||
termscp is a terminal file transfer client with a TUI (Terminal User Interface), supporting SFTP, SCP, FTP/FTPS, Kube, S3, SMB, and WebDAV protocols. It features a dual-pane file explorer (local + remote), bookmarks, system keyring integration, file watching/sync, an embedded terminal, and customizable themes.
|
||||
termscp is a terminal file transfer client with a TUI (Terminal User Interface), supporting SFTP, SCP, FTP/FTPS, Kube, S3, GCS, SMB, and WebDAV protocols. It features a dual-pane file explorer (local + remote), bookmarks, system keyring integration, file watching/sync, an embedded terminal, and customizable themes.
|
||||
|
||||
- **Language**: Rust (edition 2024, MSRV 1.89.0)
|
||||
- **Language**: Rust (edition 2024, MSRV 1.98.0)
|
||||
- **UI Framework**: tuirealm v3 (built on crossterm)
|
||||
- **File Transfer**: remotefs ecosystem
|
||||
|
||||
## Build & Development Commands
|
||||
|
||||
Task runner is `just` (modular recipes under `just/*.just`, imported by root `justfile`). Run `just --list` for the full set.
|
||||
|
||||
```bash
|
||||
# Build
|
||||
cargo build
|
||||
cargo build --release
|
||||
cargo build --no-default-features # minimal build without SMB/keyring
|
||||
just build_crates # cargo build --workspace
|
||||
just build_crates "--release"
|
||||
cargo build --no-default-features # minimal build without SMB/keyring (no just recipe)
|
||||
|
||||
# Test (CI-equivalent)
|
||||
cargo test --no-default-features --features github-actions --no-fail-fast
|
||||
just test "--no-default-features --features github-actions --no-fail-fast"
|
||||
|
||||
# Run a single test
|
||||
# Run a single test / a module (use cargo directly, just recipes don't take test names)
|
||||
cargo test <test_name> -- --nocapture
|
||||
|
||||
# Run tests for a module
|
||||
cargo test --lib filetransfer::
|
||||
cargo test --lib config::params::tests
|
||||
|
||||
# Lint
|
||||
cargo clippy -- -Dwarnings
|
||||
just clippy "-- -D warnings"
|
||||
|
||||
# Format
|
||||
cargo fmt --all -- --check # check only
|
||||
cargo fmt --all # fix
|
||||
# Format (dprint: Markdown, TOML, YAML, and Rust via nightly rustfmt)
|
||||
just fmt_check # check only
|
||||
just fmt # fix
|
||||
|
||||
# All code checks at once (fmt_check, clippy -D warnings, doc, deny, install script lint)
|
||||
just check_code
|
||||
```
|
||||
|
||||
### System Dependencies (for building)
|
||||
@@ -66,16 +69,16 @@ main.rs → parse CLI args → ActivityManager::new() → ActivityManager::run()
|
||||
|
||||
### Key Modules
|
||||
|
||||
| Module | Path | Purpose |
|
||||
|--------|------|---------|
|
||||
| **activity_manager** | `src/activity_manager.rs` | Orchestrates activity lifecycle and transitions |
|
||||
| **ui/activities** | `src/ui/activities/{auth,filetransfer,setup}/` | Three main screens, each implementing the `Activity` trait |
|
||||
| **ui/context** | `src/ui/context.rs` | Shared `Context` struct (terminal, config, bookmarks, theme) |
|
||||
| **filetransfer** | `src/filetransfer/` | Protocol enum, `RemoteFsBuilder`, connection parameters |
|
||||
| **host** | `src/host/` | `HostBridge` trait — abstracts local (`Localhost`) and remote (`RemoteBridged`) file operations |
|
||||
| **explorer** | `src/explorer/` | `FileExplorer` — directory navigation, sorting, filtering, transfer queue |
|
||||
| **system** | `src/system/` | `BookmarksClient`, `ConfigClient`, `ThemeProvider`, `SshKeyStorage`, `KeyStorage` trait |
|
||||
| **config** | `src/config/` | TOML-based serialization for themes, bookmarks, user params |
|
||||
| Module | Path | Purpose |
|
||||
| -------------------- | ---------------------------------------------- | ----------------------------------------------------------------------------------------------- |
|
||||
| **activity_manager** | `src/activity_manager.rs` | Orchestrates activity lifecycle and transitions |
|
||||
| **ui/activities** | `src/ui/activities/{auth,filetransfer,setup}/` | Three main screens, each implementing the `Activity` trait |
|
||||
| **ui/context** | `src/ui/context.rs` | Shared `Context` struct (terminal, config, bookmarks, theme) |
|
||||
| **filetransfer** | `src/filetransfer/` | Protocol enum, `RemoteFsBuilder`, connection parameters |
|
||||
| **host** | `src/host/` | `HostBridge` trait — abstracts local (`Localhost`) and remote (`RemoteBridged`) file operations |
|
||||
| **explorer** | `src/explorer/` | `FileExplorer` — directory navigation, sorting, filtering, transfer queue |
|
||||
| **system** | `src/system/` | `BookmarksClient`, `ConfigClient`, `ThemeProvider`, `SshKeyStorage`, `KeyStorage` trait |
|
||||
| **config** | `src/config/` | TOML-based serialization for themes, bookmarks, user params |
|
||||
|
||||
### Core Traits
|
||||
|
||||
@@ -86,6 +89,7 @@ main.rs → parse CLI args → ActivityManager::new() → ActivityManager::run()
|
||||
### Conditional Compilation
|
||||
|
||||
The `build.rs` defines cfg aliases via `cfg_aliases`:
|
||||
|
||||
- `posix`, `macos`, `linux`, `win` — platform shortcuts
|
||||
- `smb`, `smb_unix`, `smb_windows` — feature + platform combinations
|
||||
|
||||
@@ -93,7 +97,7 @@ Platform-specific dependencies: SSH and FTP crates use different TLS backends on
|
||||
|
||||
### File Transfer Protocols
|
||||
|
||||
`FileTransferProtocol` enum maps to protocol-specific parameter types (`ProtocolParams` enum) and `RemoteFsBuilder` constructs the appropriate `RemoteFs` client. Each protocol has its own params struct (e.g., `GenericProtocolParams` for SSH-based, `AwsS3Params`, `KubeProtocolParams`, `SmbParams`, `WebDAVProtocolParams`).
|
||||
`FileTransferProtocol` enum maps to protocol-specific parameter types (`ProtocolParams` enum) and `RemoteFsBuilder` constructs the appropriate `RemoteFs` client. Each protocol has its own params struct (e.g., `GenericProtocolParams` for SSH-based, `AwsS3Params`, `GoogleCloudStorageParams`, `KubeProtocolParams`, `SmbParams`, `WebDAVProtocolParams`).
|
||||
|
||||
## Code Conventions
|
||||
|
||||
@@ -106,6 +110,7 @@ Platform-specific dependencies: SSH and FTP crates use different TLS backends on
|
||||
|
||||
## Other conventions
|
||||
|
||||
- Always run `cargo +nightly fmt --all` and `cargo clippy --no-default-features -- -Dwarnings` after modifying Rust code
|
||||
- Always run `just fmt` and `just clippy "-- -D warnings"` after modifying Rust code
|
||||
- Always put plans to `./.claude/plans/`
|
||||
- When changing behavior that is documented under `docs/` (paths, config keys, commands, flags, etc.), update BOTH `docs/en-US/` and `docs/zh-CN/` to keep the translations in sync
|
||||
- All code must be cross-platform compatible (Windows, macOS, Linux) — avoid POSIX-only APIs, hardcoded path separators, or shell-specific behavior unless gated behind the `posix`/`win` cfg aliases
|
||||
|
||||
+10
-10
@@ -17,23 +17,23 @@ diverse, inclusive, and healthy community.
|
||||
Examples of behavior that contributes to a positive environment for our
|
||||
community include:
|
||||
|
||||
* Demonstrating empathy and kindness toward other people
|
||||
* Being respectful of differing opinions, viewpoints, and experiences
|
||||
* Giving and gracefully accepting constructive feedback
|
||||
* Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
- Demonstrating empathy and kindness toward other people
|
||||
- Being respectful of differing opinions, viewpoints, and experiences
|
||||
- Giving and gracefully accepting constructive feedback
|
||||
- Accepting responsibility and apologizing to those affected by our mistakes,
|
||||
and learning from the experience
|
||||
* Focusing on what is best not just for us as individuals, but for the overall
|
||||
- Focusing on what is best not just for us as individuals, but for the overall
|
||||
community
|
||||
|
||||
Examples of unacceptable behavior include:
|
||||
|
||||
* The use of sexualized language or imagery, and sexual attention or advances of
|
||||
- The use of sexualized language or imagery, and sexual attention or advances of
|
||||
any kind
|
||||
* Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
* Public or private harassment
|
||||
* Publishing others' private information, such as a physical or email address,
|
||||
- Trolling, insulting or derogatory comments, and personal or political attacks
|
||||
- Public or private harassment
|
||||
- Publishing others' private information, such as a physical or email address,
|
||||
without their explicit permission
|
||||
* Other conduct which could reasonably be considered inappropriate in a
|
||||
- Other conduct which could reasonably be considered inappropriate in a
|
||||
professional setting
|
||||
|
||||
## Enforcement Responsibilities
|
||||
|
||||
+1
-1
@@ -150,5 +150,5 @@ You can view the developer guide [here](https://docs.termscp.rs/en-US/developer/
|
||||
|
||||
---
|
||||
|
||||
Thank you for any contribution!
|
||||
Thank you for any contribution!\
|
||||
Christian Visintin
|
||||
|
||||
Generated
+1721
-1689
File diff suppressed because it is too large
Load Diff
+17
-29
@@ -1,17 +1,17 @@
|
||||
[package]
|
||||
name = "termscp"
|
||||
version = "1.1.1"
|
||||
edition = "2024"
|
||||
authors = ["Christian Visintin <christian.visintin@veeso.dev>"]
|
||||
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV"
|
||||
license = "MIT"
|
||||
repository = "https://github.com/veeso/termscp"
|
||||
categories = ["command-line-utilities"]
|
||||
edition = "2024"
|
||||
homepage = "https://termscp.rs"
|
||||
include = ["src/**/*", "build.rs", "LICENSE", "README.md", "CHANGELOG.md"]
|
||||
include = ["/src/**/*", "/build.rs", "/LICENSE", "/README.md", "/CHANGELOG.md"]
|
||||
keywords = ["terminal", "ftp", "scp", "sftp", "tui"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
rust-version = "1.89.0"
|
||||
repository = "https://github.com/veeso/termscp"
|
||||
rust-version = "1.98.0"
|
||||
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV"
|
||||
|
||||
[package.metadata.rpm]
|
||||
package = "termscp"
|
||||
@@ -37,9 +37,9 @@ smb-vendored = ["remotefs-smb/vendored"]
|
||||
|
||||
[dependencies]
|
||||
aes = "0.9"
|
||||
aes-gcm = "0.10"
|
||||
aes-gcm = "0.11"
|
||||
argh = "0.1"
|
||||
base64 = "0.22"
|
||||
base64 = "0.23"
|
||||
bitflags = "2"
|
||||
bytesize = "2"
|
||||
cbc = { version = "0.2", features = ["alloc"] }
|
||||
@@ -60,25 +60,19 @@ rand = "0.10"
|
||||
regex = "1"
|
||||
remotefs = "0.3"
|
||||
remotefs-aws-s3 = "0.4"
|
||||
remotefs-gcs = "0.1"
|
||||
remotefs-kube = "0.4"
|
||||
remotefs-smb = { version = "0.3", optional = true }
|
||||
remotefs-ssh = { version = "0.8", default-features = false, features = [
|
||||
"russh",
|
||||
] }
|
||||
remotefs-smb = { version = "0.5", default-features = false, optional = true, features = ["find", "pavao"] }
|
||||
remotefs-ssh = { version = "0.9", default-features = false, features = ["russh"] }
|
||||
remotefs-webdav = "0.2"
|
||||
rpassword = "7"
|
||||
self_update = { version = "0.42", default-features = false, features = [
|
||||
"archive-tar",
|
||||
"archive-zip",
|
||||
"compression-flate2",
|
||||
"compression-zip-deflate",
|
||||
"rustls",
|
||||
] }
|
||||
self_update = { version = "0.42", default-features = false, features = ["archive-tar", "archive-zip", "compression-flate2", "compression-zip-deflate", "rustls"] }
|
||||
semver = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
shellexpand = "3"
|
||||
simplelog = "0.12"
|
||||
ssh2-config = "0.7"
|
||||
ssh2-config = "0.8"
|
||||
tempfile = "3"
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["rt"] }
|
||||
@@ -91,16 +85,10 @@ whoami = "2"
|
||||
wildmatch = "2"
|
||||
|
||||
[target."cfg(any(target_os = \"linux\", target_os = \"freebsd\"))".dependencies]
|
||||
dbus-secret-service-keyring-store = { version = "1", features = [
|
||||
"crypto-rust",
|
||||
"vendored",
|
||||
] }
|
||||
dbus-secret-service-keyring-store = { version = "1", features = ["crypto-rust", "vendored"] }
|
||||
|
||||
[target."cfg(target_family = \"unix\")".dependencies]
|
||||
remotefs-ftp = { version = "0.4", features = [
|
||||
"native-tls",
|
||||
"native-tls-vendored",
|
||||
] }
|
||||
remotefs-ftp = { version = "0.4", features = ["native-tls", "native-tls-vendored"] }
|
||||
uzers = "0.12"
|
||||
|
||||
[target."cfg(target_family = \"windows\")".dependencies]
|
||||
@@ -114,7 +102,7 @@ windows-native-keyring-store = "1"
|
||||
|
||||
[dev-dependencies]
|
||||
pretty_assertions = "1"
|
||||
serial_test = "3"
|
||||
serial_test = "4"
|
||||
|
||||
[build-dependencies]
|
||||
cfg_aliases = "0.2"
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
import "./just/build.just"
|
||||
import "./just/changelog.just"
|
||||
import "./just/code_check.just"
|
||||
import "./just/publish.just"
|
||||
import "./just/run.just"
|
||||
import "./just/site.just"
|
||||
import "./just/test.just"
|
||||
|
||||
# Lists all the available commands
|
||||
default:
|
||||
@just --list
|
||||
@@ -35,7 +35,7 @@
|
||||
|
||||
## About termscp 🖥
|
||||
|
||||
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/WebDAV. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **FreeBSD**, **NetBSD** and **Windows** compatible.
|
||||
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/Google Cloud Storage (GCS)/WebDAV. So basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and to interact with the local file system. It is **Linux**, **MacOS**, **FreeBSD**, **NetBSD** and **Windows** compatible.
|
||||
|
||||

|
||||
|
||||
@@ -43,39 +43,40 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
|
||||
|
||||
## Features 🎁
|
||||
|
||||
- 📁 Different communication protocols
|
||||
- 📁 Different communication protocols
|
||||
- **SFTP**
|
||||
- **SCP**
|
||||
- **FTP** and **FTPS**
|
||||
- **Kube**
|
||||
- **S3**
|
||||
- **Google Cloud Storage (GCS)**
|
||||
- **SMB**
|
||||
- **WebDAV**
|
||||
- 🖥 Explore and operate on the remote and on the local machine file system with a handy UI
|
||||
- 🖥 Explore and operate on the remote and on the local machine file system with a handy UI
|
||||
- Create, remove, rename, search, view and edit files
|
||||
- ⭐ Connect to your favourite hosts through built-in bookmarks and recent connections
|
||||
- 📝 View and edit files with your favourite applications
|
||||
- 💁 SFTP/SCP authentication with SSH keys and username/password
|
||||
- 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS
|
||||
- 🐚 Embedded terminal for executing commands on the system.
|
||||
- 🎨 Make it yours!
|
||||
- ⭐ Connect to your favourite hosts through built-in bookmarks and recent connections
|
||||
- 📝 View and edit files with your favourite applications
|
||||
- 💁 SFTP/SCP authentication with SSH keys and username/password
|
||||
- 🐧 Compatible with Windows, Linux, FreeBSD, NetBSD and MacOS
|
||||
- 🐚 Embedded terminal for executing commands on the system.
|
||||
- 🎨 Make it yours!
|
||||
- Themes
|
||||
- Custom file explorer format
|
||||
- Customizable text editor
|
||||
- Customizable file sorting
|
||||
- and many other parameters...
|
||||
- 📫 Get notified via Desktop Notifications when a large file has been transferred
|
||||
- 🔭 Keep file changes synchronized with the remote host
|
||||
- 🔐 Save your password in your operating system key vault
|
||||
- 🦀 Rust-powered
|
||||
- 👀 Developed keeping an eye on performance
|
||||
- 🦄 Frequent awesome updates
|
||||
- 📫 Get notified via Desktop Notifications when a large file has been transferred
|
||||
- 🔭 Keep file changes synchronized with the remote host
|
||||
- 🔐 Save your password in your operating system key vault
|
||||
- 🦀 Rust-powered
|
||||
- 👀 Developed keeping an eye on performance
|
||||
- 🦄 Frequent awesome updates
|
||||
|
||||
---
|
||||
|
||||
## Get started 🚀
|
||||
|
||||
If you're considering to install termscp I want to thank you 💜 ! I hope you will enjoy termscp!
|
||||
If you're considering to install termscp I want to thank you 💜 ! I hope you will enjoy termscp!\
|
||||
If you want to contribute to this project, don't forget to check out our [contribute guide](CONTRIBUTING.md).
|
||||
|
||||
If you are a Linux, a FreeBSD or a MacOS user this simple shell script will install termscp on your system with a single command:
|
||||
@@ -131,10 +132,10 @@ These requirements are not forced required to run termscp, but to enjoy all of i
|
||||
|
||||
- **Linux/FreeBSD** users:
|
||||
- To **open** files via `V` (at least one of these)
|
||||
- *xdg-open*
|
||||
- *gio*
|
||||
- *gnome-open*
|
||||
- *kde-open*
|
||||
- _xdg-open_
|
||||
- _gio_
|
||||
- _gnome-open_
|
||||
- _kde-open_
|
||||
- **Linux** users:
|
||||
- A keyring manager: read more in the [User manual](https://docs.termscp.rs/en-US/configuration/password-security.html#linux-keyring)
|
||||
- **WSL** users
|
||||
|
||||
@@ -0,0 +1,84 @@
|
||||
# cargo-deny configuration for termscp.
|
||||
#
|
||||
# Run locally with `just deny`. The same command runs in CI and enforces
|
||||
# advisories, licenses, bans, and dependency sources.
|
||||
#
|
||||
# Docs: https://embarkstudios.github.io/cargo-deny/
|
||||
|
||||
[graph]
|
||||
all-features = true
|
||||
|
||||
[output]
|
||||
feature-depth = 1
|
||||
|
||||
[advisories]
|
||||
db-urls = ["https://github.com/rustsec/advisory-db"]
|
||||
yanked = "deny"
|
||||
unmaintained = "all"
|
||||
# These are temporary, reachable transitive-risk acceptances owned by the
|
||||
# termscp maintainers. Review or remove every exception by 2026-12-31. The
|
||||
# upgrade target is a remotefs/self_update release line that accepts each fixed
|
||||
# dependency version; until then, connections to untrusted endpoints remain an
|
||||
# acknowledged denial-of-service and certificate-validation risk.
|
||||
ignore = [
|
||||
# h2 0.3 is retained transitively by the reqwest 0.11/AWS HTTP stack; its
|
||||
# dependency constraints cannot select the fixed h2 0.4 release.
|
||||
{ id = "RUSTSEC-2026-0258", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade reqwest/AWS stack to fixed h2" },
|
||||
# number_prefix is retained by self_update through indicatif. It is
|
||||
# unmaintained, but has no reported vulnerability or compatible replacement.
|
||||
{ id = "RUSTSEC-2025-0119", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade self_update/indicatif" },
|
||||
# Both affected quick-xml lines are constrained by remotefs-webdav and
|
||||
# self_update to versions older than the fixed 0.41 release.
|
||||
{ id = "RUSTSEC-2026-0194", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade remotefs-webdav/self_update to quick-xml >=0.41" },
|
||||
{ id = "RUSTSEC-2026-0195", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade remotefs-webdav/self_update to quick-xml >=0.41" },
|
||||
# The SSH stack requires RSA, for which RustSec reports no safe upgrade.
|
||||
{ id = "RUSTSEC-2023-0071", reason = "owner: termscp maintainers; review by 2026-12-31; replace RSA dependency when upstream fix exists" },
|
||||
# rustls-pemfile is retained by the WebDAV and Kubernetes clients. Both
|
||||
# versions are unmaintained but have no reported vulnerability.
|
||||
{ id = "RUSTSEC-2025-0134", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade WebDAV/Kubernetes clients away from rustls-pemfile" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
allow = [
|
||||
"Apache-2.0",
|
||||
"BSD-1-Clause",
|
||||
"BSD-2-Clause",
|
||||
"BSD-3-Clause",
|
||||
"BSL-1.0",
|
||||
"CC0-1.0",
|
||||
"CDLA-Permissive-2.0",
|
||||
"ISC",
|
||||
"MIT",
|
||||
"MIT-0",
|
||||
"MPL-2.0",
|
||||
"Unicode-3.0",
|
||||
"Unlicense",
|
||||
"Zlib",
|
||||
]
|
||||
confidence-threshold = 0.8
|
||||
exceptions = [
|
||||
# The WebDAV backend currently depends on rustydav, which declares GPL-3.0.
|
||||
# Keep this exception crate-scoped so no other GPL dependency is admitted.
|
||||
{ allow = ["GPL-3.0"], crate = "rustydav" },
|
||||
]
|
||||
|
||||
[licenses.private]
|
||||
ignore = false
|
||||
|
||||
[bans]
|
||||
multiple-versions = "warn"
|
||||
wildcards = "deny"
|
||||
allow-wildcard-paths = true
|
||||
highlight = "all"
|
||||
workspace-default-features = "allow"
|
||||
external-default-features = "allow"
|
||||
allow = []
|
||||
deny = []
|
||||
skip = []
|
||||
skip-tree = []
|
||||
|
||||
[sources]
|
||||
unknown-registry = "deny"
|
||||
unknown-git = "deny"
|
||||
allow-registry = ["https://github.com/rust-lang/crates.io-index"]
|
||||
allow-git = []
|
||||
Vendored
+4
@@ -4,6 +4,10 @@
|
||||
set -euo pipefail
|
||||
|
||||
VERSION="${1:?usage: bump_version.sh <version> [date] [root]}"
|
||||
if [[ ! "$VERSION" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "invalid release version: $VERSION (expected MAJOR.MINOR.PATCH)" >&2
|
||||
exit 2
|
||||
fi
|
||||
DATE="${2:-$(date +%F)}"
|
||||
ROOT="${3:-$(git rev-parse --show-toplevel)}"
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[book]
|
||||
title = "termscp"
|
||||
description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV"
|
||||
description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV"
|
||||
authors = ["Christian Visintin"]
|
||||
language = "en"
|
||||
src = "."
|
||||
|
||||
@@ -48,3 +48,15 @@ The following parameters can be configured:
|
||||
attributes supported by termscp are listed at
|
||||
[the ssh2-config exposed attributes](https://github.com/veeso/ssh2-config#exposed-attributes).
|
||||
See also [SSH key storage](ssh-keys.md).
|
||||
|
||||
## SSH configuration behavior
|
||||
|
||||
In either SFTP or SCP authentication pane, after you change and leave the
|
||||
`Host` field, termscp keeps the entered alias visible and fills the visible
|
||||
`Port` and `Username` fields from the matching SSH configuration entry. If the
|
||||
new Host does not match an entry, it resets Port to `22` and Username to empty.
|
||||
`HostName` never replaces the alias in the form.
|
||||
|
||||
Bookmarks retain their saved Port and User values; the SSH configuration does
|
||||
not silently override them. The configured file still provides `HostName` and
|
||||
other supported SSH options when termscp connects.
|
||||
|
||||
@@ -47,20 +47,20 @@ are two quick fixes:
|
||||
1. Re-import the official theme. After each release the official themes are
|
||||
patched, so download the updated theme from the repository and re-import it:
|
||||
|
||||
```sh
|
||||
termscp theme <theme.toml>
|
||||
```
|
||||
```sh
|
||||
termscp theme <theme.toml>
|
||||
```
|
||||
|
||||
2. Edit your theme by hand. If you use a custom theme, edit the file and add the
|
||||
missing key. The theme is located at `$CONFIG_DIR/theme.toml`, where
|
||||
`$CONFIG_DIR` is:
|
||||
|
||||
- FreeBSD/Linux: `$HOME/.config/termscp`
|
||||
- macOS: `$HOME/.config/termscp`
|
||||
- Windows: `%USERPROFILE%\.termscp`
|
||||
- FreeBSD/Linux: `$HOME/.config/termscp`
|
||||
- macOS: `$HOME/.config/termscp`
|
||||
- Windows: `%USERPROFILE%\.termscp`
|
||||
|
||||
Missing keys are reported in the CHANGELOG under `BREAKING CHANGES` for the
|
||||
version you have just installed.
|
||||
Missing keys are reported in the CHANGELOG under `BREAKING CHANGES` for the
|
||||
version you have just installed.
|
||||
|
||||
## Styles
|
||||
|
||||
|
||||
@@ -6,7 +6,7 @@ documentation for termscp modules, which can instead be found on Rust Docs at
|
||||
guidelines to implement features such as file transfers and additions to the
|
||||
user interface.
|
||||
|
||||
termscp is written in Rust (edition 2024, MSRV 1.89.0). The user interface is
|
||||
termscp is written in Rust (edition 2024, MSRV 1.98.0). The user interface is
|
||||
built with [tuirealm](https://github.com/veeso/tui-realm) v3, which runs on top
|
||||
of [crossterm](https://github.com/crossterm-rs/crossterm).
|
||||
|
||||
@@ -37,8 +37,8 @@ In addition to the 3 core modules, others have been added over time:
|
||||
storage and the bookmarks.
|
||||
- **utils**: contains the utilities used by pretty much all of the project.
|
||||
|
||||
termscp supports the following protocols: SFTP, SCP, FTP/FTPS, Kube, S3, SMB and
|
||||
WebDAV.
|
||||
termscp supports the following protocols: SFTP, SCP, FTP/FTPS, Kube, S3, GCS,
|
||||
SMB and WebDAV.
|
||||
|
||||
## Activities
|
||||
|
||||
@@ -63,8 +63,8 @@ works best from different frameworks:
|
||||
more, read <https://github.com/veeso/tui-realm>.
|
||||
- **Components**: components are built around tui in order to reuse widgets. This
|
||||
is achieved through the `Component` trait, inspired by
|
||||
[React](https://reactjs.org/). Each component has its *Properties* and can have
|
||||
its *States*. Each component must handle input events, accept new properties,
|
||||
[React](https://reactjs.org/). Each component has its _Properties_ and can have
|
||||
its _States_. Each component must handle input events, accept new properties,
|
||||
and provide a method to **render** itself. This logic now lives in
|
||||
[tui-realm](https://github.com/veeso/tui-realm).
|
||||
- **Messages: an Elm-based approach**: input events are handled with an approach
|
||||
|
||||
@@ -18,6 +18,18 @@ When termscp starts without an address, it shows the authentication form. Fill
|
||||
in the protocol, address, port, username, and password, then connect. termscp
|
||||
will open the dual-pane explorer once the connection succeeds.
|
||||
|
||||
## SSH configuration precedence
|
||||
|
||||
For SFTP and SCP connections, termscp resolves CLI Username and Port values in
|
||||
this order: explicit `Username`/`Port`, SSH configuration `User`/`Port`, then
|
||||
the current OS user and Port `22`. Username and Port are resolved independently,
|
||||
so an explicit value for one does not prevent SSH configuration from supplying
|
||||
the other.
|
||||
|
||||
For example, `termscp myhost` uses the configured Port and User for `myhost`.
|
||||
`termscp alice@myhost:22` uses `alice` and `22`, regardless of the SSH
|
||||
configuration.
|
||||
|
||||
## Address argument syntax
|
||||
|
||||
The generic address argument has the following syntax:
|
||||
@@ -30,8 +42,10 @@ This syntax is convenient, and you will probably use it instead of the
|
||||
interactive form. Here are some examples.
|
||||
|
||||
Connect using the default protocol (defined in your configuration) to
|
||||
`192.168.1.31`. If the port is not provided, the default port for the selected
|
||||
protocol is used. The username is the current user's name.
|
||||
`192.168.1.31`. For SFTP and SCP, an omitted Port or Username is taken from a
|
||||
matching SSH configuration entry. If no value is configured, it falls back to
|
||||
the protocol default port or the current OS user. Other protocols use their
|
||||
default port and the current OS user.
|
||||
|
||||
```sh
|
||||
termscp 192.168.1.31
|
||||
@@ -56,7 +70,7 @@ directory `/tmp`:
|
||||
termscp scp://omar@192.168.1.31:4022:/tmp
|
||||
```
|
||||
|
||||
For protocol-specific address syntax (S3, Kube, WebDAV, and SMB), see
|
||||
For protocol-specific address syntax (S3, GCS, Kube, WebDAV, and SMB), see
|
||||
[Connection parameters](connection-parameters.md).
|
||||
|
||||
## How the password is provided
|
||||
|
||||
@@ -128,6 +128,37 @@ ways to do this.
|
||||
Your credentials are safe: termscp does not manipulate these values directly.
|
||||
They are consumed directly by the `s3` crate.
|
||||
|
||||
## Google Cloud Storage
|
||||
|
||||
termscp supports Google Cloud Storage (GCS) buckets through the Google Cloud
|
||||
Storage JSON API.
|
||||
|
||||
Authentication-form fields:
|
||||
|
||||
- Bucket name (required)
|
||||
- Endpoint (defaults to `https://storage.googleapis.com`)
|
||||
- Optional service-account JSON path
|
||||
|
||||
Leave the service-account JSON path empty to use Application Default
|
||||
Credentials (ADC). ADC can obtain credentials from
|
||||
`GOOGLE_APPLICATION_CREDENTIALS`, local gcloud ADC credentials, or the Google
|
||||
Cloud metadata service when termscp runs on Google Cloud infrastructure.
|
||||
|
||||
When a service-account JSON path is supplied, termscp reads that file when it
|
||||
connects. Bookmarks store only the path and never copy the service-account JSON
|
||||
or its private key.
|
||||
|
||||
The dedicated CLI syntax is:
|
||||
|
||||
```txt
|
||||
gcs://<bucket>[:/working/directory]
|
||||
```
|
||||
|
||||
CLI connections use ADC and the default endpoint. Use the authentication form
|
||||
or a bookmark when you need a custom endpoint or a service-account JSON path.
|
||||
The selected ADC identity or service account must have IAM permissions for the
|
||||
storage operations you want to perform.
|
||||
|
||||
## SMB
|
||||
|
||||
Authentication-form fields:
|
||||
@@ -138,8 +169,28 @@ Authentication-form fields:
|
||||
- Password
|
||||
- Port (other systems only; default `445`)
|
||||
- Workgroup (other systems only)
|
||||
- SMB version (other systems only; default `Auto`)
|
||||
|
||||
On Windows the port and workgroup fields are not used.
|
||||
On Windows the port, workgroup and SMB version fields are not used: the
|
||||
operating system manages SMB protocol negotiation.
|
||||
|
||||
The SMB version field bounds the dialects offered during negotiation:
|
||||
|
||||
| Selection | Dialects negotiated |
|
||||
| --------- | ----------------------- |
|
||||
| Auto | SMB 2.0.2 through 3.1.1 |
|
||||
| SMB1 | NT1 (CIFS) only |
|
||||
| SMB2 | SMB 2.0.2 through 2.1 |
|
||||
| SMB3 | SMB 3.0 through 3.1.1 |
|
||||
|
||||
`Auto` never negotiates SMB1. SMB1 is deprecated and insecure: select it only
|
||||
for isolated legacy devices that support nothing newer. A warning is shown in
|
||||
the form while SMB1 is selected.
|
||||
|
||||
Bookmarks store the selection under the `dialect` key of the SMB table
|
||||
(`auto`, `smb1`, `smb2` or `smb3`). Bookmarks saved before this option existed
|
||||
have no `dialect` key and behave as `Auto`. The address syntax below does not
|
||||
carry a version; connections started from the command line use `Auto`.
|
||||
|
||||
Windows address syntax:
|
||||
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@ same time. termscp runs on Linux, macOS, FreeBSD, NetBSD, and Windows.
|
||||
|
||||
## Features
|
||||
|
||||
- Multiple transfer protocols: SFTP, SCP, FTP and FTPS, Kube, S3, SMB,
|
||||
- Multiple transfer protocols: SFTP, SCP, FTP and FTPS, Kube, S3, GCS, SMB,
|
||||
and WebDAV.
|
||||
- Dual-pane explorer to browse and operate on both the remote and the local
|
||||
file system: create, remove, rename, search, view, and edit files.
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/shared/termscp.svg">
|
||||
|
||||
<meta property="og:title" content="{{#if chapter_title}}{{ chapter_title }} · {{/if}}{{ book_title }}">
|
||||
<meta property="og:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV">
|
||||
<meta property="og:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV">
|
||||
<meta property="og:image" content="https://docs.termscp.rs/og_preview.jpg">
|
||||
<meta property="og:url" content="https://docs.termscp.rs/">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{#if chapter_title}}{{ chapter_title }} · {{/if}}{{ book_title }}">
|
||||
<meta name="twitter:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV">
|
||||
<meta name="twitter:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV">
|
||||
<meta name="twitter:image" content="https://docs.termscp.rs/og_preview.jpg">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/WebDAV.
|
||||
Termscp is a feature rich terminal file transfer and explorer, with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV.
|
||||
Basically is a terminal utility with an TUI to connect to a remote server to retrieve and upload files and
|
||||
to interact with the local file system.
|
||||
|
||||
@@ -9,6 +9,9 @@ Features:
|
||||
- SCP
|
||||
- FTP and FTPS
|
||||
- S3
|
||||
- GCS
|
||||
- SMB
|
||||
- WebDAV
|
||||
- 🖥 Explore and operate on the remote and on the local machine file system with a handy UI
|
||||
- Create, remove, rename, search, view and edit files
|
||||
- ⭐ Connect to your favourite hosts through built-in bookmarks and recent connections
|
||||
|
||||
+21
-20
@@ -35,7 +35,7 @@
|
||||
|
||||
## 关于 termscp 🖥
|
||||
|
||||
termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/SFTP/FTP/Kube/S3/WebDAV。 简而言之,它是一个带有 TUI 的终端工具,可以连接到远程服务器进行文件的检索和上传,并能够与本地文件系统进行交互。 它兼容 **Linux**、**MacOS**、**FreeBSD**、**NetBSD** 和 **Windows** 操作系统。
|
||||
termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/SFTP/FTP/Kube/S3/Google Cloud Storage (GCS)/WebDAV。 简而言之,它是一个带有 TUI 的终端工具,可以连接到远程服务器进行文件的检索和上传,并能够与本地文件系统进行交互。 它兼容 **Linux**、**MacOS**、**FreeBSD**、**NetBSD** 和 **Windows** 操作系统。
|
||||
|
||||

|
||||
|
||||
@@ -43,39 +43,40 @@ termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/S
|
||||
|
||||
## 特性 🎁
|
||||
|
||||
- 📁 支持多种通信协议
|
||||
- 📁 支持多种通信协议
|
||||
- **SFTP**
|
||||
- **SCP**
|
||||
- **FTP** 和 **FTPS**
|
||||
- **Kube**
|
||||
- **S3**
|
||||
- **Google Cloud Storage (GCS)**
|
||||
- **SMB**
|
||||
- **WebDAV**
|
||||
- 🖥 使用便捷的 UI 在远程和本地文件系统上浏览和操作
|
||||
- 🖥 使用便捷的 UI 在远程和本地文件系统上浏览和操作
|
||||
- 创建、删除、重命名、搜索、查看和编辑文件
|
||||
- ⭐ 通过“内置书签”和“最近连接”快速连接到您喜爱的主机
|
||||
- 📝 使用您喜欢的应用程序查看和编辑文件
|
||||
- 💁 使用 SSH 密钥和用户名/密码进行 SFTP/SCP 身份验证
|
||||
- 🐧 兼容 Windows、Linux、FreeBSD、NetBSD 和 MacOS 操作系统
|
||||
- 🐚 内置终端,可在系统上执行命令。
|
||||
- 🎨 丰富的个性化设置!
|
||||
- ⭐ 通过“内置书签”和“最近连接”快速连接到您喜爱的主机
|
||||
- 📝 使用您喜欢的应用程序查看和编辑文件
|
||||
- 💁 使用 SSH 密钥和用户名/密码进行 SFTP/SCP 身份验证
|
||||
- 🐧 兼容 Windows、Linux、FreeBSD、NetBSD 和 MacOS 操作系统
|
||||
- 🐚 内置终端,可在系统上执行命令。
|
||||
- 🎨 丰富的个性化设置!
|
||||
- 主题
|
||||
- 自定义文件浏览器格式
|
||||
- 可自定义的文本编辑器
|
||||
- 可自定义的文件排序
|
||||
- 以及许多其他参数...
|
||||
- 📫 传输大文件时通过桌面通知获得提醒
|
||||
- 🔭 与远程主机文件更改保持同步
|
||||
- 🔐 将密码保存在操作系统密钥保管库中
|
||||
- 🦀 由 Rust 提供强力支持
|
||||
- 👀 开发时更注重性能
|
||||
- 🦄 频繁的精彩更新
|
||||
- 📫 传输大文件时通过桌面通知获得提醒
|
||||
- 🔭 与远程主机文件更改保持同步
|
||||
- 🔐 将密码保存在操作系统密钥保管库中
|
||||
- 🦀 由 Rust 提供强力支持
|
||||
- 👀 开发时更注重性能
|
||||
- 🦄 频繁的精彩更新
|
||||
|
||||
---
|
||||
|
||||
## 开始 🚀
|
||||
|
||||
如果您正在考虑安装 termscp,我想对您表示感谢 💜 ! 希望您会喜欢 termscp!
|
||||
如果您正在考虑安装 termscp,我想对您表示感谢 💜 ! 希望您会喜欢 termscp!\
|
||||
如果您想为此项目做出贡献,请不要忘记查看我们的[贡献指南](CONTRIBUTING.md)。
|
||||
|
||||
如果您是 Linux、FreeBSD 或 MacOS 用户,使用以下简单的 shell 脚本即可通过单行指令在您的系统上安装 termscp:
|
||||
@@ -131,10 +132,10 @@ pacman -S termscp
|
||||
|
||||
- **Linux/FreeBSD** 用户:
|
||||
- 用 `V` **打开**文件(至少其中之一)
|
||||
- *xdg-open*
|
||||
- *gio*
|
||||
- *gnome-open*
|
||||
- *kde-open*
|
||||
- _xdg-open_
|
||||
- _gio_
|
||||
- _gnome-open_
|
||||
- _kde-open_
|
||||
- **Linux** 用户:
|
||||
- 密钥环管理器:在[用户手册](https://docs.termscp.rs/zh-CN/configuration/password-security.html#linux-密钥环)中阅读更多内容
|
||||
- **WSL** 用户
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[book]
|
||||
title = "termscp"
|
||||
description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV"
|
||||
description = "A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV"
|
||||
authors = ["Christian Visintin"]
|
||||
language = "zh"
|
||||
src = "."
|
||||
|
||||
+10
-10
@@ -18,16 +18,16 @@ termscp [options]... -b [bookmark-name] -b [bookmark-name] [local-wrkdir]
|
||||
|
||||
## 选项
|
||||
|
||||
| Key | 说明 |
|
||||
| -------------------- | --------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `-b <bookmark-name>` | 将位置地址参数解析为书签名称。重复使用该标志可以打开多个书签。 |
|
||||
| `-D` | 启用 `TRACE` 日志级别(调试/详细日志)。 |
|
||||
| `-P <password>` | 从命令行提供密码。重复使用该标志可为多个远程主机提供密码;其顺序必须与地址参数一致。不推荐使用。 |
|
||||
| `-q` | 禁用日志记录。 |
|
||||
| `-T <ticks>` | 设置 UI 的 tick 间隔(以毫秒为单位)。默认值为 `10`。 |
|
||||
| `--wno-keyring` | 禁用系统 keyring 支持。 |
|
||||
| `-v` | 打印版本信息。 |
|
||||
| `--help` | 打印帮助页面。 |
|
||||
| Key | 说明 |
|
||||
| -------------------- | ------------------------------------------------ |
|
||||
| `-b <bookmark-name>` | 将位置地址参数解析为书签名称。重复使用该标志可以打开多个书签。 |
|
||||
| `-D` | 启用 `TRACE` 日志级别(调试/详细日志)。 |
|
||||
| `-P <password>` | 从命令行提供密码。重复使用该标志可为多个远程主机提供密码;其顺序必须与地址参数一致。不推荐使用。 |
|
||||
| `-q` | 禁用日志记录。 |
|
||||
| `-T <ticks>` | 设置 UI 的 tick 间隔(以毫秒为单位)。默认值为 `10`。 |
|
||||
| `--wno-keyring` | 禁用系统 keyring 支持。 |
|
||||
| `-v` | 打印版本信息。 |
|
||||
| `--help` | 打印帮助页面。 |
|
||||
|
||||
不推荐使用 `-P` 选项,因为密码可能会保留在 shell 历史记录中。请参阅书签和密码安全章节,了解更安全的凭据提供方式。
|
||||
|
||||
|
||||
@@ -25,3 +25,9 @@ termscp 要求以下路径可访问:
|
||||
- **启用通知**:如果设置为 `Yes`,则会显示桌面通知。参见 [通知](notifications.md)。
|
||||
- **通知:最小传输大小**:如果传输大小大于或等于指定值,则显示传输通知。可接受的格式为 `{UNSIGNED} B/KB/MB/GB/TB/PB`。
|
||||
- **SSH 配置路径**:连接到 SCP/SFTP 服务器时使用的 SSH 配置文件。如果留空,则不使用任何文件。你可以指定以 `~` 开头的路径来表示主目录(例如 `~/.ssh/config`)。termscp 支持的属性列于 [ssh2-config 公开的属性](https://github.com/veeso/ssh2-config#exposed-attributes)。另请参见 [SSH 密钥存储](ssh-keys.md)。
|
||||
|
||||
## SSH 配置行为
|
||||
|
||||
在 SFTP 或 SCP 的任一认证面板中,更改并离开 `Host` 字段后,termscp 会保持输入的别名可见,并使用匹配的 SSH 配置条目填充可见的 `Port` 和 `Username` 字段。如果新的 Host 不匹配任何条目,Port 会重置为 `22`,Username 会重置为空。`HostName` 永远不会替换表单中的别名。
|
||||
|
||||
书签会保留其保存的 Port 和 User 值;SSH 配置不会在幕后覆盖它们。连接时,已配置的文件仍会提供 `HostName` 和其他受支持的 SSH 选项。
|
||||
|
||||
@@ -20,18 +20,18 @@
|
||||
|
||||
以下是格式化器支持的键:
|
||||
|
||||
| 键 | 说明 |
|
||||
| --------- | ------------------------------------------------------------------------------------------------ |
|
||||
| `ATIME` | 最后访问时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{ATIME:8:%H:%M}`) |
|
||||
| `CTIME` | 创建时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{CTIME:8:%H:%M}`) |
|
||||
| `GROUP` | 所属组 |
|
||||
| `MTIME` | 最后修改时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{MTIME:8:%H:%M}`) |
|
||||
| `NAME` | 文件名(如果根目录与首个祖先目录之间的文件夹长度超过 `LENGTH`,则会被省略) |
|
||||
| `PATH` | 文件绝对路径(如果根目录与首个祖先目录之间的文件夹长度超过 `LENGTH`,则会被省略) |
|
||||
| `PEX` | 文件权限(UNIX 格式) |
|
||||
| `SIZE` | 文件大小(目录省略) |
|
||||
| `SYMLINK` | 符号链接目标(如果有,`-> {FILE_PATH}`) |
|
||||
| `USER` | 所属用户 |
|
||||
| 键 | 说明 |
|
||||
| --------- | --------------------------------------------------------------- |
|
||||
| `ATIME` | 最后访问时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{ATIME:8:%H:%M}`) |
|
||||
| `CTIME` | 创建时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{CTIME:8:%H:%M}`) |
|
||||
| `GROUP` | 所属组 |
|
||||
| `MTIME` | 最后修改时间(默认 `%b %d %Y %H:%M`);`EXTRA` 为时间格式(例如 `{MTIME:8:%H:%M}`) |
|
||||
| `NAME` | 文件名(如果根目录与首个祖先目录之间的文件夹长度超过 `LENGTH`,则会被省略) |
|
||||
| `PATH` | 文件绝对路径(如果根目录与首个祖先目录之间的文件夹长度超过 `LENGTH`,则会被省略) |
|
||||
| `PEX` | 文件权限(UNIX 格式) |
|
||||
| `SIZE` | 文件大小(目录省略) |
|
||||
| `SYMLINK` | 符号链接目标(如果有,`-> {FILE_PATH}`) |
|
||||
| `USER` | 所属用户 |
|
||||
|
||||
## 默认格式
|
||||
|
||||
|
||||
@@ -36,17 +36,17 @@ termscp 接受以下颜色格式:
|
||||
|
||||
1. 重新导入官方主题。每次发布后,官方主题都会被修补,因此从仓库下载更新后的主题并重新导入:
|
||||
|
||||
```sh
|
||||
termscp theme <theme.toml>
|
||||
```
|
||||
```sh
|
||||
termscp theme <theme.toml>
|
||||
```
|
||||
|
||||
2. 手动编辑你的主题。如果你使用自定义主题,请编辑该文件并添加缺失的键。主题位于 `$CONFIG_DIR/theme.toml`,其中 `$CONFIG_DIR` 为:
|
||||
|
||||
- FreeBSD/Linux:`$HOME/.config/termscp`
|
||||
- macOS:`$HOME/.config/termscp`
|
||||
- Windows:`%USERPROFILE%\.termscp`
|
||||
- FreeBSD/Linux:`$HOME/.config/termscp`
|
||||
- macOS:`$HOME/.config/termscp`
|
||||
- Windows:`%USERPROFILE%\.termscp`
|
||||
|
||||
缺失的键会在你刚安装的版本的 CHANGELOG 中 `BREAKING CHANGES` 部分列出。
|
||||
缺失的键会在你刚安装的版本的 CHANGELOG 中 `BREAKING CHANGES` 部分列出。
|
||||
|
||||
## 样式
|
||||
|
||||
@@ -54,44 +54,44 @@ termscp 接受以下颜色格式:
|
||||
|
||||
### 认证页面
|
||||
|
||||
| 键 | 说明 |
|
||||
| ---------------- | -------------------------- |
|
||||
| `auth_address` | IP 地址输入框的颜色 |
|
||||
| `auth_bookmarks` | 书签面板的颜色 |
|
||||
| `auth_password` | 密码输入框的颜色 |
|
||||
| `auth_port` | 端口号输入框的颜色 |
|
||||
| `auth_protocol` | 协议单选框组的颜色 |
|
||||
| `auth_recents` | 最近记录面板的颜色 |
|
||||
| `auth_username` | 用户名输入框的颜色 |
|
||||
| 键 | 说明 |
|
||||
| ---------------- | ----------- |
|
||||
| `auth_address` | IP 地址输入框的颜色 |
|
||||
| `auth_bookmarks` | 书签面板的颜色 |
|
||||
| `auth_password` | 密码输入框的颜色 |
|
||||
| `auth_port` | 端口号输入框的颜色 |
|
||||
| `auth_protocol` | 协议单选框组的颜色 |
|
||||
| `auth_recents` | 最近记录面板的颜色 |
|
||||
| `auth_username` | 用户名输入框的颜色 |
|
||||
|
||||
### 传输页面
|
||||
|
||||
| 键 | 说明 |
|
||||
| -------------------------------------- | -------------------------------------------------- |
|
||||
| `transfer_local_explorer_background` | 本地主机浏览器的背景色 |
|
||||
| `transfer_local_explorer_foreground` | 本地主机浏览器的前景色 |
|
||||
| `transfer_local_explorer_highlighted` | 本地主机浏览器的边框及高亮颜色 |
|
||||
| `transfer_remote_explorer_background` | 远程浏览器的背景色 |
|
||||
| `transfer_remote_explorer_foreground` | 远程浏览器的前景色 |
|
||||
| `transfer_remote_explorer_highlighted` | 远程浏览器的边框及高亮颜色 |
|
||||
| `transfer_log_background` | 日志面板的背景色 |
|
||||
| `transfer_log_window` | 日志面板的窗口颜色 |
|
||||
| `transfer_progress_bar_partial` | 部分进度条的颜色 |
|
||||
| `transfer_progress_bar_total` | 总进度条的颜色 |
|
||||
| `transfer_status_hidden` | 状态栏 "hidden" 标签的颜色 |
|
||||
| 键 | 说明 |
|
||||
| -------------------------------------- | ------------------------------- |
|
||||
| `transfer_local_explorer_background` | 本地主机浏览器的背景色 |
|
||||
| `transfer_local_explorer_foreground` | 本地主机浏览器的前景色 |
|
||||
| `transfer_local_explorer_highlighted` | 本地主机浏览器的边框及高亮颜色 |
|
||||
| `transfer_remote_explorer_background` | 远程浏览器的背景色 |
|
||||
| `transfer_remote_explorer_foreground` | 远程浏览器的前景色 |
|
||||
| `transfer_remote_explorer_highlighted` | 远程浏览器的边框及高亮颜色 |
|
||||
| `transfer_log_background` | 日志面板的背景色 |
|
||||
| `transfer_log_window` | 日志面板的窗口颜色 |
|
||||
| `transfer_progress_bar_partial` | 部分进度条的颜色 |
|
||||
| `transfer_progress_bar_total` | 总进度条的颜色 |
|
||||
| `transfer_status_hidden` | 状态栏 "hidden" 标签的颜色 |
|
||||
| `transfer_status_sorting` | 状态栏 "sorting" 标签的颜色;也适用于文件排序对话框 |
|
||||
| `transfer_status_sync_browsing` | 状态栏 "sync browsing" 标签的颜色 |
|
||||
| `transfer_status_sync_browsing` | 状态栏 "sync browsing" 标签的颜色 |
|
||||
|
||||
### 杂项
|
||||
|
||||
这些样式适用于应用程序的不同部分。
|
||||
|
||||
| 键 | 说明 |
|
||||
| ------------------- | -------------------------------- |
|
||||
| `misc_error_dialog` | 错误消息的颜色 |
|
||||
| `misc_info_dialog` | 信息对话框的颜色 |
|
||||
| 键 | 说明 |
|
||||
| ------------------- | ---------------- |
|
||||
| `misc_error_dialog` | 错误消息的颜色 |
|
||||
| `misc_info_dialog` | 信息对话框的颜色 |
|
||||
| `misc_input_dialog` | 输入对话框的颜色(例如复制文件) |
|
||||
| `misc_keys` | 按键文本的颜色 |
|
||||
| `misc_quit_dialog` | 退出对话框的颜色 |
|
||||
| `misc_save_dialog` | 保存对话框的颜色 |
|
||||
| `misc_warn_dialog` | 警告对话框的颜色 |
|
||||
| `misc_keys` | 按键文本的颜色 |
|
||||
| `misc_quit_dialog` | 退出对话框的颜色 |
|
||||
| `misc_save_dialog` | 保存对话框的颜色 |
|
||||
| `misc_warn_dialog` | 警告对话框的颜色 |
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
欢迎阅读 termscp 的开发者手册。本章不包含 termscp 各模块的文档,相关文档可以在 Rust Docs 上找到:<https://docs.rs/termscp>。本章描述 termscp 的工作原理,以及实现诸如文件传输和用户界面扩展等功能的指南。
|
||||
|
||||
termscp 使用 Rust 编写(edition 2024,MSRV 1.89.0)。用户界面使用 [tuirealm](https://github.com/veeso/tui-realm) v3 构建,它运行在 [crossterm](https://github.com/crossterm-rs/crossterm) 之上。
|
||||
termscp 使用 Rust 编写(edition 2024,MSRV 1.98.0)。用户界面使用 [tuirealm](https://github.com/veeso/tui-realm) v3 构建,它运行在 [crossterm](https://github.com/crossterm-rs/crossterm) 之上。
|
||||
|
||||
## termscp 的工作原理
|
||||
|
||||
@@ -20,7 +20,7 @@ termscp 基本上由 3 个核心模块组成:
|
||||
- **system**:提供与配置、ssh 密钥存储和书签交互的方式。
|
||||
- **utils**:包含几乎整个项目都会使用的工具。
|
||||
|
||||
termscp 支持以下协议:SFTP、SCP、FTP/FTPS、Kube、S3、SMB 和 WebDAV。
|
||||
termscp 支持以下协议:SFTP、SCP、FTP/FTPS、Kube、S3、GCS、SMB 和 WebDAV。
|
||||
|
||||
## Activities
|
||||
|
||||
@@ -30,7 +30,7 @@ termscp 支持以下协议:SFTP、SCP、FTP/FTPS、Kube、S3、SMB 和 WebDAV
|
||||
|
||||
- **顶层的 Activities**:每个“视图”都是一个 Activity,并由 `Activity Manager` 来处理它们。这种方法受 Android 启发。它适用于具有不同视图的 ui,每个视图都有自己的组件和逻辑。Activities 与 `Context` 协作,`Context` 是用于在 activities 之间共享数据的数据持有者。
|
||||
- **Activities 显示 Applications**:每个 activity 可以显示不同的 **Applications**。一个 application 包含一个 **View**,它基本上是一个 **components** 列表,每个组件都有其属性。view 是组件的门面,同时也处理焦点,即当前处于活动状态的组件。你不能拥有多个活动组件,因此必须对此进行处理;与此同时,如果当前组件被销毁,焦点必须交还给之前处于活动状态的组件。**Application** 负责处理所有这些工作。要了解更多信息,请阅读 <https://github.com/veeso/tui-realm>。
|
||||
- **Components**:components 是围绕 tui 构建的,以便复用控件。这是通过 `Component` trait 实现的,该 trait 受 [React](https://reactjs.org/) 启发。每个组件都有其 *Properties*,并且可以拥有其 *States*。每个组件必须处理输入事件、接受新的属性,并提供一个用于**渲染**自身的方法。这一逻辑现在位于 [tui-realm](https://github.com/veeso/tui-realm) 中。
|
||||
- **Components**:components 是围绕 tui 构建的,以便复用控件。这是通过 `Component` trait 实现的,该 trait 受 [React](https://reactjs.org/) 启发。每个组件都有其 _Properties_,并且可以拥有其 _States_。每个组件必须处理输入事件、接受新的属性,并提供一个用于**渲染**自身的方法。这一逻辑现在位于 [tui-realm](https://github.com/veeso/tui-realm) 中。
|
||||
- **Messages:基于 Elm 的方法**:输入事件采用受 [Elm](https://elm-lang.org/) 启发的方法来处理。在 Elm 中,你使用三个基本函数来实现 ui:**update**、**view** 和 **init**。termscp 将 Elm update 函数的等价实现编写为一个递归函数内部的大型 match 分支,你可以在每个 activity 的 `update.rs` 文件中找到它。这个 match 分支处理组件为响应传入的输入事件而产生的消息,并促使 activity 改变其状态。
|
||||
|
||||
termscp 实现了一个名为 `Activity` 的 trait,它是 Android activity 的一个大幅精简版本。该 trait 提供以下方法:
|
||||
|
||||
@@ -12,6 +12,12 @@ termscp 可以根据你传入的参数以三种不同的方式启动。
|
||||
|
||||
当 termscp 在不带地址的情况下启动时,会显示认证表单。填写协议、地址、端口、用户名和密码,然后进行连接。连接成功后,termscp 将打开双面板浏览器。
|
||||
|
||||
## SSH 配置优先级
|
||||
|
||||
对于 SFTP 和 SCP 连接,termscp 会按以下顺序解析 CLI 的 Username 和 Port 值:显式指定的 `Username`/`Port`、SSH 配置中的 `User`/`Port`,然后是当前 OS 用户和 Port `22`。Username 和 Port 会独立解析,因此其中一个值被显式指定不会阻止 SSH 配置提供另一个值。
|
||||
|
||||
例如,`termscp myhost` 会使用为 `myhost` 配置的 Port 和 User。无论 SSH 配置为何,`termscp alice@myhost:22` 都会使用 `alice` 和 `22`。
|
||||
|
||||
## 地址参数语法
|
||||
|
||||
通用地址参数采用以下语法:
|
||||
@@ -22,7 +28,7 @@ termscp 可以根据你传入的参数以三种不同的方式启动。
|
||||
|
||||
这种语法很方便,你很可能会用它来代替交互式表单。下面是一些示例。
|
||||
|
||||
使用默认协议(在你的配置中定义)连接到 `192.168.1.31`。如果未提供端口,则使用所选协议的默认端口。用户名为当前用户的名称。
|
||||
使用默认协议(在你的配置中定义)连接到 `192.168.1.31`。对于 SFTP 和 SCP,未提供的 Port 或 Username 会从匹配的 SSH 配置条目中获取。如果没有配置相应的值,则会回退到协议默认 Port 或当前 OS 用户。其他协议会使用其默认 Port 和当前 OS 用户。
|
||||
|
||||
```sh
|
||||
termscp 192.168.1.31
|
||||
@@ -46,7 +52,7 @@ termscp scp://omar@192.168.1.31:4022
|
||||
termscp scp://omar@192.168.1.31:4022:/tmp
|
||||
```
|
||||
|
||||
有关各协议专属的地址语法(S3、Kube、WebDAV 和 SMB),请参阅[连接参数](connection-parameters.md)。
|
||||
有关各协议专属的地址语法(S3、GCS、Kube、WebDAV 和 SMB),请参阅[连接参数](connection-parameters.md)。
|
||||
|
||||
## 密码的提供方式
|
||||
|
||||
|
||||
@@ -114,6 +114,33 @@ s3://buckethead@eu-central-1:default:/assets
|
||||
|
||||
你的凭据是安全的:termscp 不会直接操作这些值。它们由 `s3` crate 直接使用。
|
||||
|
||||
## Google Cloud Storage
|
||||
|
||||
termscp 通过 Google Cloud Storage JSON API 支持 Google Cloud Storage(GCS)存储桶。
|
||||
|
||||
认证表单字段:
|
||||
|
||||
- 存储桶名称(必填)
|
||||
- 端点(默认为 `https://storage.googleapis.com`)
|
||||
- 可选的服务账号 JSON 路径
|
||||
|
||||
将服务账号 JSON 路径留空即可使用应用默认凭据(ADC)。当 termscp 在 Google
|
||||
Cloud 基础设施上运行时,ADC 可以从 `GOOGLE_APPLICATION_CREDENTIALS`、本地
|
||||
gcloud ADC 凭据或 Google Cloud 元数据服务中获取凭据。
|
||||
|
||||
提供服务账号 JSON 路径后,termscp 会在连接时读取该文件。书签只保存该路径,
|
||||
不会复制服务账号 JSON 或其中的私钥。
|
||||
|
||||
专用的 CLI 语法如下:
|
||||
|
||||
```txt
|
||||
gcs://<bucket>[:/working/directory]
|
||||
```
|
||||
|
||||
CLI 连接使用 ADC 和默认端点。如果需要自定义端点或服务账号 JSON 路径,请使用
|
||||
认证表单或书签。所选 ADC 身份或服务账号必须拥有你想执行的存储操作所需的 IAM
|
||||
权限。
|
||||
|
||||
## SMB
|
||||
|
||||
认证表单字段:
|
||||
@@ -124,8 +151,22 @@ s3://buckethead@eu-central-1:default:/assets
|
||||
- 密码
|
||||
- 端口(仅其他系统;默认 `445`)
|
||||
- 工作组(仅其他系统)
|
||||
- SMB 版本(仅其他系统;默认 `Auto`)
|
||||
|
||||
在 Windows 上,端口和工作组字段不会被使用。
|
||||
在 Windows 上,端口、工作组和 SMB 版本字段不会被使用:SMB 协议协商由操作系统管理。
|
||||
|
||||
SMB 版本字段限定协商时可用的方言:
|
||||
|
||||
| 选项 | 协商的方言 |
|
||||
| ---- | ------------------ |
|
||||
| Auto | SMB 2.0.2 至 3.1.1 |
|
||||
| SMB1 | 仅 NT1(CIFS) |
|
||||
| SMB2 | SMB 2.0.2 至 2.1 |
|
||||
| SMB3 | SMB 3.0 至 3.1.1 |
|
||||
|
||||
`Auto` 永远不会协商 SMB1。SMB1 已弃用且不安全:仅在无法支持更新协议的隔离旧设备上选择它。选择 SMB1 时,表单中会显示警告。
|
||||
|
||||
书签使用 SMB 表中的 `dialect` 键保存所选项(`auto`、`smb1`、`smb2` 或 `smb3`)。在此选项出现之前保存的书签没有 `dialect` 键,其行为等同于 `Auto`。下方的地址语法不包含版本;从命令行发起的连接使用 `Auto`。
|
||||
|
||||
Windows 地址语法:
|
||||
|
||||
|
||||
+1
-1
@@ -6,7 +6,7 @@ termscp 是一款功能丰富、带有 TUI(终端用户界面)的终端文
|
||||
|
||||
## 功能特性
|
||||
|
||||
- 支持多种传输协议:SFTP、SCP、FTP 和 FTPS、Kube、S3、SMB 以及 WebDAV。
|
||||
- 支持多种传输协议:SFTP、SCP、FTP 和 FTPS、Kube、S3、GCS、SMB 以及 WebDAV。
|
||||
- 双面板浏览器,可同时浏览并操作远程和本地文件系统:创建、删除、重命名、搜索、查看和编辑文件。
|
||||
- 书签和最近连接记录,帮助你快速重新连接到常用的主机。
|
||||
- 使用你喜爱的编辑器查看和编辑文件。
|
||||
|
||||
@@ -2,12 +2,12 @@
|
||||
<link rel="icon" type="image/svg+xml" href="/shared/termscp.svg">
|
||||
|
||||
<meta property="og:title" content="{{#if chapter_title}}{{ chapter_title }} · {{/if}}{{ book_title }}">
|
||||
<meta property="og:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV">
|
||||
<meta property="og:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV">
|
||||
<meta property="og:image" content="https://docs.termscp.rs/og_preview.jpg">
|
||||
<meta property="og:url" content="https://docs.termscp.rs/">
|
||||
<meta property="og:type" content="website">
|
||||
|
||||
<meta name="twitter:card" content="summary_large_image">
|
||||
<meta name="twitter:title" content="{{#if chapter_title}}{{ chapter_title }} · {{/if}}{{ book_title }}">
|
||||
<meta name="twitter:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/SMB/WebDAV">
|
||||
<meta name="twitter:description" content="A feature rich terminal UI file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/GCS/SMB/WebDAV">
|
||||
<meta name="twitter:image" content="https://docs.termscp.rs/og_preview.jpg">
|
||||
|
||||
@@ -2,48 +2,48 @@
|
||||
|
||||
以下按键可在文件浏览器中使用。随时按 `<H|F1>` 可打开应用内帮助。
|
||||
|
||||
| 按键 | 操作 |
|
||||
| -------------- | ---------------------------------------------------------------------- |
|
||||
| `<ESC>` | 断开与远程的连接并返回认证页面 |
|
||||
| `<BACKSPACE>` | 返回导航栈中的上一个目录 |
|
||||
| `<TAB>` | 切换当前活动的浏览器选项卡 |
|
||||
| `<RIGHT>` | 移动到远程浏览器选项卡 |
|
||||
| `<LEFT>` | 移动到本地浏览器选项卡 |
|
||||
| `<UP>` | 在所选列表中向上移动 |
|
||||
| `<DOWN>` | 在所选列表中向下移动 |
|
||||
| `<PGUP>` | 在所选列表中向上移动 8 行 |
|
||||
| `<PGDOWN>` | 在所选列表中向下移动 8 行 |
|
||||
| `<ENTER>` | 进入所选目录 |
|
||||
| `<SPACE>` | 上传或下载所选文件 |
|
||||
| `<BACKTAB>` | 在日志选项卡与浏览器之间切换 |
|
||||
| `<A>` | 切换是否显示隐藏文件 |
|
||||
| `<B>` | 选择文件的排序方式 |
|
||||
| `<C\|F5>` | 复制所选文件或目录 |
|
||||
| `<D\|F7>` | 新建目录 |
|
||||
| `<E\|F8\|DEL>` | 删除所选文件 |
|
||||
| `<F>` | 搜索文件(支持通配符匹配) |
|
||||
| `<G>` | 跳转到指定路径 |
|
||||
| `<H\|F1>` | 显示帮助 |
|
||||
| `<I>` | 显示所选文件或目录的信息 |
|
||||
| `<K>` | 创建指向当前所选条目的符号链接 |
|
||||
| `<L>` | 重新加载当前目录的内容,或清除当前选择 |
|
||||
| `<M>` | 选择一个文件 |
|
||||
| `<N>` | 使用提供的名称创建新文件 |
|
||||
| `<O\|F4>` | 在文本编辑器中编辑所选文件 |
|
||||
| `<P>` | 打开日志面板 |
|
||||
| `<Q\|F10>` | 退出 termscp |
|
||||
| `<R\|F6>` | 重命名所选文件 |
|
||||
| `<S\|F2>` | 将所选文件另存为新名称 |
|
||||
| `<T>` | 将所选路径上的更改同步到远程 |
|
||||
| `<U>` | 进入上级目录 |
|
||||
| `<V\|F3>` | 使用该文件类型的默认程序打开所选文件 |
|
||||
| `<W>` | 使用你指定的程序打开所选文件 |
|
||||
| `<X>` | 执行命令 |
|
||||
| `<Y>` | 切换同步浏览 |
|
||||
| `<Z>` | 更改文件模式 |
|
||||
| `</>` | 过滤文件(同时支持正则表达式和通配符匹配) |
|
||||
| `<CTRL+A>` | 选择所有文件 |
|
||||
| `<ALT+A>` | 取消选择所有文件 |
|
||||
| `<CTRL+C>` | 中止文件传输过程 |
|
||||
| `<CTRL+S>` | 获取所选路径的总大小 |
|
||||
| `<CTRL+T>` | 显示所有已同步的路径 |
|
||||
| 按键 | 操作 |
|
||||
| -------------- | --------------------- |
|
||||
| `<ESC>` | 断开与远程的连接并返回认证页面 |
|
||||
| `<BACKSPACE>` | 返回导航栈中的上一个目录 |
|
||||
| `<TAB>` | 切换当前活动的浏览器选项卡 |
|
||||
| `<RIGHT>` | 移动到远程浏览器选项卡 |
|
||||
| `<LEFT>` | 移动到本地浏览器选项卡 |
|
||||
| `<UP>` | 在所选列表中向上移动 |
|
||||
| `<DOWN>` | 在所选列表中向下移动 |
|
||||
| `<PGUP>` | 在所选列表中向上移动 8 行 |
|
||||
| `<PGDOWN>` | 在所选列表中向下移动 8 行 |
|
||||
| `<ENTER>` | 进入所选目录 |
|
||||
| `<SPACE>` | 上传或下载所选文件 |
|
||||
| `<BACKTAB>` | 在日志选项卡与浏览器之间切换 |
|
||||
| `<A>` | 切换是否显示隐藏文件 |
|
||||
| `<B>` | 选择文件的排序方式 |
|
||||
| `<C\|F5>` | 复制所选文件或目录 |
|
||||
| `<D\|F7>` | 新建目录 |
|
||||
| `<E\|F8\|DEL>` | 删除所选文件 |
|
||||
| `<F>` | 搜索文件(支持通配符匹配) |
|
||||
| `<G>` | 跳转到指定路径 |
|
||||
| `<H\|F1>` | 显示帮助 |
|
||||
| `<I>` | 显示所选文件或目录的信息 |
|
||||
| `<K>` | 创建指向当前所选条目的符号链接 |
|
||||
| `<L>` | 重新加载当前目录的内容,或清除当前选择 |
|
||||
| `<M>` | 选择一个文件 |
|
||||
| `<N>` | 使用提供的名称创建新文件 |
|
||||
| `<O\|F4>` | 在文本编辑器中编辑所选文件 |
|
||||
| `<P>` | 打开日志面板 |
|
||||
| `<Q\|F10>` | 退出 termscp |
|
||||
| `<R\|F6>` | 重命名所选文件 |
|
||||
| `<S\|F2>` | 将所选文件另存为新名称 |
|
||||
| `<T>` | 将所选路径上的更改同步到远程 |
|
||||
| `<U>` | 进入上级目录 |
|
||||
| `<V\|F3>` | 使用该文件类型的默认程序打开所选文件 |
|
||||
| `<W>` | 使用你指定的程序打开所选文件 |
|
||||
| `<X>` | 执行命令 |
|
||||
| `<Y>` | 切换同步浏览 |
|
||||
| `<Z>` | 更改文件模式 |
|
||||
| `</>` | 过滤文件(同时支持正则表达式和通配符匹配) |
|
||||
| `<CTRL+A>` | 选择所有文件 |
|
||||
| `<ALT+A>` | 取消选择所有文件 |
|
||||
| `<CTRL+C>` | 中止文件传输过程 |
|
||||
| `<CTRL+S>` | 获取所选路径的总大小 |
|
||||
| `<CTRL+T>` | 显示所有已同步的路径 |
|
||||
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"$schema": "https://dprint.dev/schemas/v0.json",
|
||||
"indentWidth": 2,
|
||||
"lineWidth": 80,
|
||||
"newLineKind": "lf",
|
||||
"markdown": {
|
||||
"textWrap": "maintain"
|
||||
},
|
||||
"toml": {},
|
||||
"yaml": {},
|
||||
"exec": {
|
||||
"cwd": "${configDir}",
|
||||
"commands": [
|
||||
{
|
||||
"command": "rustup run nightly rustfmt --edition 2024",
|
||||
"exts": ["rs"],
|
||||
"cacheKeyFiles": ["rustfmt.toml", "rust-toolchain.toml"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"excludes": [
|
||||
"**/target",
|
||||
"**/node_modules",
|
||||
"**/*-lock.json",
|
||||
"Cargo.lock",
|
||||
"docs/book",
|
||||
"docs/zh-CN/cli/cli.md",
|
||||
"docs/zh-CN/configuration/explorer-format.md",
|
||||
"docs/zh-CN/configuration/themes.md",
|
||||
"docs/zh-CN/usage/keyboard-shortcuts.md",
|
||||
"**/tests/fixtures"
|
||||
],
|
||||
"plugins": [
|
||||
"https://plugins.dprint.dev/markdown-0.22.1.wasm@4906fbb038977732aae0e216e4b0b957e2722f8d79282660ccb36d27dd051f17",
|
||||
"https://plugins.dprint.dev/toml-0.7.0.wasm@0126c8112691542d30b52a639076ecc83e07bace877638cee7c6915fd36b8629",
|
||||
"https://plugins.dprint.dev/g-plane/pretty_yaml-v0.6.0.wasm@40a2fdda7040317eb1b23520f3a00769a5571eedb049c4ca9175c1b9eeba01ae",
|
||||
"https://plugins.dprint.dev/dprint/exec-0.6.2.json@df98f54ffd3092b8a841aedd6d098a2651f16d0a796a40535774f1a8b4b9d463"
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
# Build everything
|
||||
[group('build')]
|
||||
build_all: build_crates
|
||||
|
||||
# Build the Rust crate
|
||||
[group('build')]
|
||||
build_crates args="":
|
||||
cargo build --workspace {{ args }}
|
||||
|
||||
# Build all Rust crates in release mode
|
||||
[group('build')]
|
||||
build_crates_release:
|
||||
just build_crates "--release"
|
||||
|
||||
# Build a release binary for a target triple
|
||||
[group('build')]
|
||||
build_release target features="":
|
||||
cargo build --locked --release --target {{ target }} {{ features }}
|
||||
|
||||
# Package an already-built Linux release as a Debian package
|
||||
[group('build')]
|
||||
package_deb target:
|
||||
cargo deb --no-build --target {{ target }} --features smb-vendored
|
||||
|
||||
# Update Cargo.lock; pass cargo update arguments to scope the update.
|
||||
[group('build')]
|
||||
update_lock args="":
|
||||
cargo update {{ args }}
|
||||
|
||||
# Clean build artifacts
|
||||
[group('build')]
|
||||
[confirm("Are you sure you want to clean the build artifacts?")]
|
||||
clean:
|
||||
cargo clean
|
||||
@@ -0,0 +1,44 @@
|
||||
set positional-arguments
|
||||
|
||||
# Print the unreleased changelog section to stdout (preview only), e.g. `just changelog_preview 8.1.0`
|
||||
[group('changelog')]
|
||||
changelog_preview version:
|
||||
git-cliff --config cliff.toml --unreleased --tag "v$1" --strip all
|
||||
|
||||
# Add a new entry to CHANGELOG.md from the unreleased conventional commits, e.g. `just changelog 8.1.0`
|
||||
[group('changelog')]
|
||||
changelog version:
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
version="$1"
|
||||
if [[ ! "$version" =~ ^(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]]; then
|
||||
echo "invalid release version: $version (expected MAJOR.MINOR.PATCH)" >&2
|
||||
exit 2
|
||||
fi
|
||||
tag="v$version"
|
||||
section="$(git-cliff --config cliff.toml --unreleased --tag "$tag" --strip all)"
|
||||
if [ -z "$section" ]; then
|
||||
echo "No unreleased conventional commits found; nothing to add." >&2
|
||||
exit 0
|
||||
fi
|
||||
anchor="$(printf '%s' "$version" | tr -d '.')"
|
||||
secfile="$(mktemp)"
|
||||
tmp="$(mktemp)"
|
||||
printf '%s\n' "$section" > "$secfile"
|
||||
# Insert a TOC entry before the first existing version entry and the rendered
|
||||
# section before the first existing version heading, keeping the title + TOC.
|
||||
awk -v secfile="$secfile" -v ver="$version" -v anc="$anchor" '
|
||||
!toc_done && /^[[:space:]]*- \[[0-9]/ {
|
||||
print " - [" ver "](#" anc ")"
|
||||
toc_done = 1
|
||||
}
|
||||
!body_done && /^## [0-9]/ {
|
||||
while ((getline line < secfile) > 0) print line
|
||||
print ""
|
||||
body_done = 1
|
||||
}
|
||||
{ print }
|
||||
' CHANGELOG.md > "$tmp"
|
||||
rm -f "$secfile"
|
||||
mv "$tmp" CHANGELOG.md
|
||||
echo "CHANGELOG.md updated for $tag"
|
||||
@@ -0,0 +1,52 @@
|
||||
alias lint := clippy
|
||||
|
||||
# Format all sources (Markdown, TOML, YAML and Rust via nightly rustfmt) with dprint
|
||||
[group('code_check')]
|
||||
fmt args="":
|
||||
dprint fmt {{ args }}
|
||||
|
||||
# Check formatting of all sources with dprint (no writes)
|
||||
[group('code_check')]
|
||||
fmt_check args="":
|
||||
dprint check {{ args }}
|
||||
|
||||
# Run clippy on all targets
|
||||
[group('code_check')]
|
||||
clippy args="":
|
||||
cargo clippy --workspace --all-targets {{ args }}
|
||||
|
||||
# Build the crate documentation, denying warnings
|
||||
[group('code_check')]
|
||||
doc args="":
|
||||
RUSTDOCFLAGS="-D warnings" cargo doc --workspace --no-deps {{ args }}
|
||||
|
||||
# Check dependencies for advisories, licenses, bans and sources (cargo-deny)
|
||||
[group('code_check')]
|
||||
deny args="":
|
||||
cargo deny check {{ args }}
|
||||
|
||||
# Point git at the tracked .githooks directory (installs the pre-commit hook)
|
||||
[group('code_check')]
|
||||
setup_githooks:
|
||||
git config core.hooksPath .githooks
|
||||
@echo "git hooks installed: core.hooksPath = .githooks"
|
||||
|
||||
# Lint the install scripts (shellcheck; PowerShell parse when pwsh is available)
|
||||
[group('code_check')]
|
||||
check_install_scripts:
|
||||
sh -n install.sh
|
||||
shellcheck install.sh
|
||||
@if command -v pwsh >/dev/null 2>&1; then \
|
||||
pwsh -NoProfile -Command '$t = $null; $e = $null; $null = [System.Management.Automation.Language.Parser]::ParseFile("install.ps1", [ref]$t, [ref]$e); if ($e) { $e; exit 1 }'; \
|
||||
else \
|
||||
echo "pwsh not found: skipping install.ps1 parse check"; \
|
||||
fi
|
||||
|
||||
# Run all code checks. Fails if any check fails
|
||||
[group('code_check')]
|
||||
check_code:
|
||||
just fmt_check
|
||||
just clippy "-- -D warnings"
|
||||
just doc
|
||||
just deny
|
||||
just check_install_scripts
|
||||
@@ -0,0 +1,4 @@
|
||||
# Publish the termscp crate
|
||||
[group('publish')]
|
||||
publish_crate args="":
|
||||
cargo publish --locked --features smb-vendored {{ args }}
|
||||
@@ -0,0 +1,13 @@
|
||||
[group('run')]
|
||||
run *args:
|
||||
@set -m; cargo run --bin termscp -- {{ args }}
|
||||
|
||||
# Build, sign, and run the macOS CLI binary with a stable development identity
|
||||
# Job control gives smista its own foreground process group, so Ctrl-C reaches it
|
||||
# without also interrupting just.
|
||||
[group('run')]
|
||||
run_signed *args:
|
||||
@test "$(uname -s)" = "Darwin" || (echo "run_signed is only supported on macOS." >&2; exit 1)
|
||||
cargo build --bin termscp
|
||||
codesign --force --sign "${TERMSCP_CODESIGN_IDENTITY:-Apple Development}" target/debug/termscp
|
||||
@set -m; target/debug/termscp {{ args }}
|
||||
@@ -0,0 +1,28 @@
|
||||
# Install website dependencies from the lockfile
|
||||
[group('site')]
|
||||
site_install:
|
||||
cd site && npm ci
|
||||
|
||||
# Check website formatting
|
||||
[group('site')]
|
||||
site_fmt_check:
|
||||
cd site && npm run format:check
|
||||
|
||||
# Run Astro and TypeScript checks
|
||||
[group('site')]
|
||||
site_check:
|
||||
cd site && npm run check
|
||||
|
||||
# Run website tests when the package defines them
|
||||
[group('site')]
|
||||
site_test:
|
||||
cd site && npm test --if-present
|
||||
|
||||
# Build the website
|
||||
[group('site')]
|
||||
site_build:
|
||||
cd site && npm run build
|
||||
|
||||
# Run every website validation step
|
||||
[group('site')]
|
||||
site_ci: site_fmt_check site_check site_test site_build
|
||||
@@ -0,0 +1,13 @@
|
||||
# Run all tests
|
||||
[group('test')]
|
||||
test_all: test
|
||||
|
||||
# Run the Rust test suite
|
||||
[group('test')]
|
||||
test args="":
|
||||
cargo test --workspace {{ args }}
|
||||
|
||||
# Generate an LCOV code coverage report for the Rust workspace (requires cargo-llvm-cov)
|
||||
[group('test')]
|
||||
coverage output="lcov.info":
|
||||
cargo llvm-cov --workspace --lcov --output-path {{ output }}
|
||||
@@ -0,0 +1,3 @@
|
||||
[toolchain]
|
||||
channel = "1.98.0"
|
||||
components = ["clippy", "rustfmt"]
|
||||
@@ -26,6 +26,12 @@ const year = new Date().getFullYear();
|
||||
data-umami-event="docs"
|
||||
data-umami-event-location="footer">User manual</a
|
||||
>
|
||||
<a
|
||||
href="/privacy"
|
||||
class="hover:text-text"
|
||||
data-umami-event="privacy"
|
||||
data-umami-event-location="footer">Privacy</a
|
||||
>
|
||||
</div>
|
||||
<p>
|
||||
termscp v{VERSION} · © {year} Christian Visintin · Released under the MIT license.
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
---
|
||||
import Base from "../layouts/Base.astro";
|
||||
import Nav from "../components/Nav.astro";
|
||||
import Footer from "../components/Footer.astro";
|
||||
|
||||
const EMAIL = "info@veeso.dev";
|
||||
---
|
||||
|
||||
<Base
|
||||
title="Privacy Policy — termscp"
|
||||
description="How termscp.rs handles your data: cookieless, privacy-first analytics with Umami, EU-hosted, and no tracking cookies."
|
||||
path="/privacy"
|
||||
>
|
||||
<Nav />
|
||||
<main class="mx-auto w-full max-w-[760px] flex-1 px-6 py-16">
|
||||
<p class="font-mono text-xs uppercase tracking-[0.12em] text-overlay">
|
||||
termscp
|
||||
</p>
|
||||
<h1 class="mt-3 text-3xl font-bold tracking-tight text-text">
|
||||
Privacy Policy
|
||||
</h1>
|
||||
<p class="mt-3 font-mono text-sm text-overlay">
|
||||
Last updated: 19 June 2026
|
||||
</p>
|
||||
|
||||
<div
|
||||
class="mt-10 flex flex-col gap-8 text-base leading-relaxed text-subtext"
|
||||
>
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">Overview</h2>
|
||||
<p>
|
||||
This website is the project page for termscp, an open-source terminal
|
||||
file transfer client released under the MIT license. We keep data
|
||||
collection to an absolute minimum: no tracking cookies, no advertising
|
||||
networks, and we never sell or share personal data. We do, however,
|
||||
process a limited amount of data that is technically unavoidable when
|
||||
you visit any website — such as your IP address and server logs
|
||||
handled by our hosting provider — together with anonymous, aggregated
|
||||
usage analytics. This policy explains what we process, why, on what
|
||||
legal basis, and the rights you have under the EU General Data
|
||||
Protection Regulation (GDPR) and Italian data protection law (D.Lgs.
|
||||
196/2003 as amended by D.Lgs. 101/2018).
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">
|
||||
Data controller & contact
|
||||
</h2>
|
||||
<p>
|
||||
The data controller is <strong class="text-text"
|
||||
>veeso.dev di Christian Visintin</strong
|
||||
>, VAT no. IT03104140300, Via Antonio Marangoni 33, 33100 Udine (UD),
|
||||
Italy. For any privacy-related question or to exercise your rights,
|
||||
you can reach out at{" "}
|
||||
<a
|
||||
href={`mailto:${EMAIL}`}
|
||||
class="font-medium text-blue underline-offset-2 hover:underline"
|
||||
>{EMAIL}</a
|
||||
>.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">Cookies</h2>
|
||||
<p>
|
||||
We do not use any cookies to track user behaviour on this website. No
|
||||
consent banner is shown because there is nothing to consent to: no
|
||||
profiling cookies, no third-party advertising cookies. Our analytics
|
||||
provider is cookieless (see below), so no consent is required under
|
||||
the Italian Garante's guidelines on cookies and tracking tools.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">
|
||||
Hosting & server logs
|
||||
</h2>
|
||||
<p>
|
||||
This website is hosted by Vercel Inc. (340 S Lemon Ave #4133, Walnut,
|
||||
CA 91789, USA), which acts as a data processor on our behalf. As with
|
||||
any web server, Vercel automatically processes technical data needed
|
||||
to deliver the site and keep it secure: your IP address, browser
|
||||
user-agent, requested URLs, referrer, and timestamps, recorded in
|
||||
server logs.
|
||||
</p>
|
||||
<p class="mt-3">
|
||||
<strong class="text-text">Legal basis:</strong> our legitimate interest
|
||||
(Art. 6(1)(f) GDPR) in operating the website, ensuring its security, and
|
||||
preventing abuse. These logs are kept only for as long as necessary for
|
||||
those purposes (typically up to 30 days) and are not used to profile you
|
||||
or build advertising audiences.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">Analytics with Umami</h2>
|
||||
<p>
|
||||
We use Umami to collect anonymous usage data so we can understand how
|
||||
visitors use the website and improve its design and functionality. We
|
||||
use the EU-hosted Umami Cloud service (<span class="font-mono"
|
||||
>cloud.umami.is</span
|
||||
>), where analytics data is stored on servers located in the European
|
||||
Union (Germany). The service is operated by Umami Software, Inc.
|
||||
</p>
|
||||
<p class="mt-3">
|
||||
Umami is cookieless and privacy-focused: it does not set cookies and
|
||||
does not store your IP address or any data that can directly identify
|
||||
you. It derives only aggregated, anonymous metrics (such as country,
|
||||
browser, and page views). We also track a small number of anonymous
|
||||
interaction events — for example clicks on the GitHub, crates.io, and
|
||||
documentation links — to measure interest in the project; none of
|
||||
these events contain personal data.
|
||||
</p>
|
||||
<p class="mt-3">
|
||||
<strong class="text-text">Legal basis:</strong> our legitimate interest
|
||||
(Art. 6(1)(f) GDPR) in measuring and improving the website. Because the
|
||||
data is anonymous and no cookies or device identifiers are used, no consent
|
||||
is required.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">
|
||||
International data transfers
|
||||
</h2>
|
||||
<p>
|
||||
Our analytics data is stored within the European Union. Some of our
|
||||
providers are US-based companies (Vercel Inc. and Umami Software,
|
||||
Inc.), so limited technical data may be processed outside the European
|
||||
Economic Area. Where this happens, transfers are protected by
|
||||
appropriate safeguards under Chapter V GDPR — namely the EU–US Data
|
||||
Privacy Framework and/or the European Commission's Standard
|
||||
Contractual Clauses, together with the relevant data processing
|
||||
agreements.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">Your rights</h2>
|
||||
<p>
|
||||
Under the GDPR you have the right to access your personal data, and to
|
||||
request its rectification, erasure, or restriction, as well as the
|
||||
right to object to processing and the right to data portability. Note
|
||||
that the analytics data we hold is anonymous and cannot be linked back
|
||||
to you, so for that data we may be unable to identify you in order to
|
||||
act on a request.
|
||||
</p>
|
||||
<p class="mt-3">
|
||||
To exercise any right, contact us at{" "}
|
||||
<a
|
||||
href={`mailto:${EMAIL}`}
|
||||
class="font-medium text-blue underline-offset-2 hover:underline"
|
||||
>{EMAIL}</a
|
||||
>. You also have the right to lodge a complaint with the Italian
|
||||
supervisory authority, the{" "}
|
||||
<a
|
||||
href="https://www.garanteprivacy.it"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
class="font-medium text-blue underline-offset-2 hover:underline"
|
||||
>Garante per la protezione dei dati personali</a
|
||||
>, or with the data protection authority of your country of residence.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">External links</h2>
|
||||
<p>
|
||||
This website links to external services such as GitHub and crates.io.
|
||||
Once you leave this site, the privacy policy of the destination
|
||||
service applies. We are not responsible for the content or privacy
|
||||
practices of external websites.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section>
|
||||
<h2 class="mb-2 text-xl font-bold text-text">Changes to this policy</h2>
|
||||
<p>
|
||||
We may update this privacy policy from time to time. Any changes will
|
||||
be published on this page with an updated "Last updated" date.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
<Footer />
|
||||
</Base>
|
||||
+170
-11
@@ -7,12 +7,13 @@ use std::path::PathBuf;
|
||||
use std::time::Duration;
|
||||
|
||||
use remotefs_ssh::SshKeyStorage as SshKeyStorageTrait;
|
||||
use ssh2_config::SshConfig;
|
||||
|
||||
use crate::cli::{Remote, RemoteArgs};
|
||||
use crate::filetransfer::{
|
||||
FileTransferParams, FileTransferProtocol, HostBridgeParams, ProtocolParams,
|
||||
};
|
||||
use crate::host::HostError;
|
||||
use crate::host::{HostError, HostErrorType};
|
||||
use crate::system::bookmarks_client::BookmarksClient;
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use crate::system::environment;
|
||||
@@ -23,7 +24,7 @@ use crate::ui::activities::filetransfer::FileTransferActivity;
|
||||
use crate::ui::activities::setup::SetupActivity;
|
||||
use crate::ui::activities::{Activity, ExitReason};
|
||||
use crate::ui::context::Context;
|
||||
use crate::utils::{fmt, tty};
|
||||
use crate::utils::{fmt, ssh as ssh_utils, tty};
|
||||
|
||||
/// NextActivity identifies the next identity to run once the current has ended
|
||||
pub enum NextActivity {
|
||||
@@ -67,7 +68,19 @@ impl ActivityManager {
|
||||
};
|
||||
let error = error_config.or(error_bookmark);
|
||||
let theme_provider: ThemeProvider = Self::init_theme_provider();
|
||||
let ctx: Context = Context::new(bookmarks_client, config_client, theme_provider, error);
|
||||
let ssh_config = config_client
|
||||
.get_ssh_config()
|
||||
.map(ssh_utils::parse_ssh2_config)
|
||||
.transpose()
|
||||
.map_err(|err| HostError::from(HostErrorType::InvalidSshConfig(err)))?;
|
||||
|
||||
let ctx: Context = Context::new(
|
||||
bookmarks_client,
|
||||
config_client,
|
||||
theme_provider,
|
||||
ssh_config,
|
||||
error,
|
||||
);
|
||||
Ok(ActivityManager {
|
||||
context: Some(ctx),
|
||||
ticks,
|
||||
@@ -83,13 +96,20 @@ impl ActivityManager {
|
||||
¶ms.name,
|
||||
params.password.as_deref(),
|
||||
),
|
||||
Remote::Host(host_params) => self.set_host_params(
|
||||
HostParams::HostBridge(HostBridgeParams::Remote(
|
||||
host_params.file_transfer_params.protocol,
|
||||
host_params.file_transfer_params.params,
|
||||
)),
|
||||
host_params.password.as_deref(),
|
||||
),
|
||||
Remote::Host(host_params) => {
|
||||
let params = apply_ssh_config_to_omitted_cli_parameters(
|
||||
host_params.file_transfer_params,
|
||||
host_params.port_explicit,
|
||||
self.context_ref()?.ssh_config(),
|
||||
);
|
||||
self.set_host_params(
|
||||
HostParams::HostBridge(HostBridgeParams::Remote(
|
||||
params.protocol,
|
||||
params.params,
|
||||
)),
|
||||
host_params.password.as_deref(),
|
||||
)
|
||||
}
|
||||
Remote::None => {
|
||||
// local dir is remote_args.local_dir if set, otherwise current dir
|
||||
let local_dir = remote_args
|
||||
@@ -112,7 +132,11 @@ impl ActivityManager {
|
||||
self.resolve_bookmark_name(Host::Remote, ¶ms.name, params.password.as_deref())
|
||||
}
|
||||
Remote::Host(host_params) => self.set_host_params(
|
||||
HostParams::Remote(host_params.file_transfer_params),
|
||||
HostParams::Remote(apply_ssh_config_to_omitted_cli_parameters(
|
||||
host_params.file_transfer_params,
|
||||
host_params.port_explicit,
|
||||
self.context_ref()?.ssh_config(),
|
||||
)),
|
||||
host_params.password.as_deref(),
|
||||
),
|
||||
Remote::None => Ok(()),
|
||||
@@ -530,3 +554,138 @@ impl ActivityManager {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Applies SSH configuration values only to CLI parameters omitted by the user.
|
||||
fn apply_ssh_config_to_omitted_cli_parameters(
|
||||
mut file_transfer_params: FileTransferParams,
|
||||
port_explicit: bool,
|
||||
ssh_config: Option<&SshConfig>,
|
||||
) -> FileTransferParams {
|
||||
if !matches!(
|
||||
file_transfer_params.protocol,
|
||||
FileTransferProtocol::Scp | FileTransferProtocol::Sftp,
|
||||
) {
|
||||
return file_transfer_params;
|
||||
}
|
||||
|
||||
if let ProtocolParams::Generic(params) = &mut file_transfer_params.params {
|
||||
let resolved = ssh_utils::resolve_ssh_host_params(ssh_config, params.address.as_str());
|
||||
if !port_explicit {
|
||||
params.port = resolved.port;
|
||||
}
|
||||
if params.username.is_none() {
|
||||
params.username = resolved.username;
|
||||
}
|
||||
}
|
||||
|
||||
file_transfer_params
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::apply_ssh_config_to_omitted_cli_parameters;
|
||||
use crate::filetransfer::params::GenericProtocolParams;
|
||||
use crate::filetransfer::{FileTransferParams, FileTransferProtocol, ProtocolParams};
|
||||
use crate::utils::ssh::parse_ssh2_config;
|
||||
use crate::utils::test_helpers;
|
||||
|
||||
#[test]
|
||||
fn should_apply_ssh_config_to_omitted_cli_port_and_username() {
|
||||
let config = ssh_config();
|
||||
let params = ssh_params(22, None);
|
||||
|
||||
let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&config));
|
||||
let resolved = resolved.params.generic_params().unwrap();
|
||||
|
||||
assert_eq!(resolved.port, 2222);
|
||||
assert_eq!(resolved.username.as_deref(), Some("configured-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_preserve_explicit_cli_port_over_ssh_config() {
|
||||
let config = ssh_config();
|
||||
let params = ssh_params(22, None);
|
||||
|
||||
let resolved = apply_ssh_config_to_omitted_cli_parameters(params, true, Some(&config));
|
||||
let resolved = resolved.params.generic_params().unwrap();
|
||||
|
||||
assert_eq!(resolved.port, 22);
|
||||
assert_eq!(resolved.username.as_deref(), Some("configured-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_preserve_explicit_cli_username_over_ssh_config() {
|
||||
let config = ssh_config();
|
||||
let params = ssh_params(22, Some("cli-user"));
|
||||
|
||||
let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&config));
|
||||
let resolved = resolved.params.generic_params().unwrap();
|
||||
|
||||
assert_eq!(resolved.port, 2222);
|
||||
assert_eq!(resolved.username.as_deref(), Some("cli-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_apply_only_omitted_cli_ssh_parameters() {
|
||||
let config = ssh_config();
|
||||
let params = ssh_params(2200, Some("cli-user"));
|
||||
|
||||
let resolved = apply_ssh_config_to_omitted_cli_parameters(params, true, Some(&config));
|
||||
let resolved = resolved.params.generic_params().unwrap();
|
||||
|
||||
assert_eq!(resolved.port, 2200);
|
||||
assert_eq!(resolved.username.as_deref(), Some("cli-user"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_default_omitted_cli_ssh_port_without_configuration() {
|
||||
let params = ssh_params(22, None);
|
||||
|
||||
let resolved = apply_ssh_config_to_omitted_cli_parameters(params, false, None);
|
||||
|
||||
assert_eq!(resolved.params.generic_params().unwrap().port, 22);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_leave_non_ssh_cli_parameters_unchanged() {
|
||||
let params = FileTransferParams::new(
|
||||
FileTransferProtocol::Ftp(false),
|
||||
ProtocolParams::Generic(
|
||||
GenericProtocolParams::default()
|
||||
.address("configured-host")
|
||||
.port(21)
|
||||
.username(Some("ftp-user")),
|
||||
),
|
||||
);
|
||||
|
||||
let resolved =
|
||||
apply_ssh_config_to_omitted_cli_parameters(params, false, Some(&ssh_config()));
|
||||
let resolved = resolved.params.generic_params().unwrap();
|
||||
|
||||
assert_eq!(resolved.port, 21);
|
||||
assert_eq!(resolved.username.as_deref(), Some("ftp-user"));
|
||||
}
|
||||
|
||||
fn ssh_params(port: u16, username: Option<&str>) -> FileTransferParams {
|
||||
FileTransferParams::new(
|
||||
FileTransferProtocol::Scp,
|
||||
ProtocolParams::Generic(
|
||||
GenericProtocolParams::default()
|
||||
.address("configured-host")
|
||||
.port(port)
|
||||
.username(username),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn ssh_config() -> ssh2_config::SshConfig {
|
||||
let config_file = test_helpers::create_sample_file_with_content(
|
||||
"Host configured-host\n Port 2222\n User configured-user\n",
|
||||
);
|
||||
|
||||
parse_ssh2_config(&config_file.path().to_string_lossy())
|
||||
.expect("test SSH configuration should parse")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ Address syntax can be:
|
||||
|
||||
- `protocol://user@address:port:wrkdir` for protocols such as Sftp, Scp, Ftp
|
||||
- `s3://bucket-name@region:profile:/wrkdir` for Aws S3 protocol
|
||||
- `gcs://<bucket>[:/working/directory]` for Google Cloud Storage (ADC)
|
||||
- `\\\\<server>[:port]\\<share>[\\path]` for SMB (on Windows)
|
||||
- `smb://[user@]<server>[:port]</share>[/path]` for SMB (on other systems)
|
||||
|
||||
|
||||
+35
-5
@@ -70,8 +70,13 @@ impl TryFrom<&Args> for RemoteArgs {
|
||||
}
|
||||
|
||||
let remote = match addr_type {
|
||||
AddrType::Address => Self::parse_remote_address(arg)
|
||||
.map(|x| Remote::Host(HostParams::new(x, password)))?,
|
||||
AddrType::Address => Self::parse_remote_address(arg).map(|parsed| {
|
||||
Remote::Host(HostParams::new(
|
||||
parsed.file_transfer_params,
|
||||
parsed.port_explicit,
|
||||
password,
|
||||
))
|
||||
})?,
|
||||
AddrType::Bookmark => Remote::Bookmark(BookmarkParams::new(arg, password.as_ref())),
|
||||
};
|
||||
|
||||
@@ -99,8 +104,9 @@ impl TryFrom<&Args> for RemoteArgs {
|
||||
|
||||
impl RemoteArgs {
|
||||
/// Parse remote address
|
||||
fn parse_remote_address(remote: &str) -> Result<FileTransferParams, String> {
|
||||
utils::parser::parse_remote_opt(remote).map_err(|e| format!("Bad address option: {e}"))
|
||||
fn parse_remote_address(remote: &str) -> Result<utils::parser::ParsedRemote, String> {
|
||||
utils::parser::parse_remote_opt_with_metadata(remote)
|
||||
.map_err(|e| format!("Bad address option: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -137,6 +143,8 @@ pub struct BookmarkParams {
|
||||
pub struct HostParams {
|
||||
/// file transfer parameters
|
||||
pub file_transfer_params: FileTransferParams,
|
||||
/// Whether the address explicitly provided a port.
|
||||
pub port_explicit: bool,
|
||||
/// host password specified in arguments
|
||||
pub password: Option<String>,
|
||||
}
|
||||
@@ -151,9 +159,14 @@ impl BookmarkParams {
|
||||
}
|
||||
|
||||
impl HostParams {
|
||||
pub fn new<S: AsRef<str>>(params: FileTransferParams, password: Option<S>) -> Self {
|
||||
pub fn new<S: AsRef<str>>(
|
||||
params: FileTransferParams,
|
||||
port_explicit: bool,
|
||||
password: Option<S>,
|
||||
) -> Self {
|
||||
Self {
|
||||
file_transfer_params: params,
|
||||
port_explicit,
|
||||
password: password.map(|x| x.as_ref().to_string()),
|
||||
}
|
||||
}
|
||||
@@ -179,6 +192,23 @@ mod test {
|
||||
assert_eq!(remote_args.local_dir, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_preserve_explicit_port_from_positional_remote() {
|
||||
for (remote, port_explicit) in [("scp://host", false), ("scp://host:22", true)] {
|
||||
let args = Args {
|
||||
positional: vec![remote.to_string()],
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
let remote_args = RemoteArgs::try_from(&args).unwrap();
|
||||
let Remote::Host(params) = remote_args.remote else {
|
||||
panic!("expected positional remote to be a host");
|
||||
};
|
||||
|
||||
assert_eq!(params.port_explicit, port_explicit, "{remote}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_make_remote_args_from_args_two_remotes() {
|
||||
let args = Args {
|
||||
|
||||
+116
-16
@@ -3,6 +3,7 @@
|
||||
//! `bookmarks` is the module which provides data types and de/serializer for bookmarks
|
||||
|
||||
mod aws_s3;
|
||||
mod gcs;
|
||||
mod kube;
|
||||
mod smb;
|
||||
|
||||
@@ -14,11 +15,12 @@ use serde::de::Error as DeError;
|
||||
use serde::{Deserialize, Deserializer, Serialize, Serializer};
|
||||
|
||||
pub use self::aws_s3::S3Params;
|
||||
pub use self::gcs::GcsParams;
|
||||
pub use self::kube::KubeParams;
|
||||
pub use self::smb::SmbParams;
|
||||
use crate::filetransfer::params::{
|
||||
AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams,
|
||||
SmbParams as TransferSmbParams, WebDAVProtocolParams,
|
||||
AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams,
|
||||
ProtocolParams, SmbParams as TransferSmbParams, WebDAVProtocolParams,
|
||||
};
|
||||
use crate::filetransfer::{FileTransferParams, FileTransferProtocol};
|
||||
|
||||
@@ -55,6 +57,8 @@ pub struct Bookmark {
|
||||
pub kube: Option<KubeParams>,
|
||||
/// S3 params; optional. When used other fields are empty for sure
|
||||
pub s3: Option<S3Params>,
|
||||
/// Google Cloud Storage params; optional. When used other fields are empty for sure
|
||||
pub gcs: Option<GcsParams>,
|
||||
/// SMB params; optional. Extra params required for SMB protocol
|
||||
pub smb: Option<SmbParams>,
|
||||
}
|
||||
@@ -78,6 +82,7 @@ impl From<FileTransferParams> for Bookmark {
|
||||
local_path,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
ProtocolParams::AwsS3(params) => Self {
|
||||
@@ -90,6 +95,7 @@ impl From<FileTransferParams> for Bookmark {
|
||||
local_path,
|
||||
kube: None,
|
||||
s3: Some(S3Params::from(params)),
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
ProtocolParams::Kube(params) => Self {
|
||||
@@ -102,6 +108,7 @@ impl From<FileTransferParams> for Bookmark {
|
||||
local_path,
|
||||
kube: Some(KubeParams::from(params)),
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
ProtocolParams::Smb(params) => Self {
|
||||
@@ -118,6 +125,7 @@ impl From<FileTransferParams> for Bookmark {
|
||||
local_path,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
},
|
||||
ProtocolParams::WebDAV(parms) => Self {
|
||||
protocol,
|
||||
@@ -129,6 +137,20 @@ impl From<FileTransferParams> for Bookmark {
|
||||
local_path,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
ProtocolParams::GoogleCloudStorage(params) => Self {
|
||||
protocol,
|
||||
address: None,
|
||||
port: None,
|
||||
username: None,
|
||||
password: None,
|
||||
remote_path,
|
||||
local_path,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: Some(GcsParams::from(params)),
|
||||
smb: None,
|
||||
},
|
||||
}
|
||||
@@ -144,6 +166,14 @@ impl From<Bookmark> for FileTransferParams {
|
||||
let params = AwsS3Params::from(params);
|
||||
Self::new(FileTransferProtocol::AwsS3, ProtocolParams::AwsS3(params))
|
||||
}
|
||||
FileTransferProtocol::GoogleCloudStorage => {
|
||||
let params = bookmark.gcs.unwrap_or_default();
|
||||
let params = GoogleCloudStorageParams::from(params);
|
||||
Self::new(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
ProtocolParams::GoogleCloudStorage(params),
|
||||
)
|
||||
}
|
||||
FileTransferProtocol::Ftp(_)
|
||||
| FileTransferProtocol::Scp
|
||||
| FileTransferProtocol::Sftp => {
|
||||
@@ -161,25 +191,25 @@ impl From<Bookmark> for FileTransferParams {
|
||||
}
|
||||
#[cfg(posix)]
|
||||
FileTransferProtocol::Smb => {
|
||||
let params = TransferSmbParams::new(
|
||||
bookmark.address.unwrap_or_default(),
|
||||
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(),
|
||||
)
|
||||
.port(bookmark.port.unwrap_or(445))
|
||||
.username(bookmark.username)
|
||||
.password(bookmark.password)
|
||||
.workgroup(bookmark.smb.and_then(|x| x.workgroup));
|
||||
let smb = bookmark.smb.unwrap_or_default();
|
||||
let params =
|
||||
TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
|
||||
.port(bookmark.port.unwrap_or(445))
|
||||
.username(bookmark.username)
|
||||
.password(bookmark.password)
|
||||
.workgroup(smb.workgroup)
|
||||
.dialect(smb.dialect.unwrap_or_default());
|
||||
|
||||
Self::new(bookmark.protocol, ProtocolParams::Smb(params))
|
||||
}
|
||||
#[cfg(win)]
|
||||
FileTransferProtocol::Smb => {
|
||||
let params = TransferSmbParams::new(
|
||||
bookmark.address.unwrap_or_default(),
|
||||
bookmark.smb.clone().map(|x| x.share).unwrap_or_default(),
|
||||
)
|
||||
.username(bookmark.username)
|
||||
.password(bookmark.password);
|
||||
let smb = bookmark.smb.unwrap_or_default();
|
||||
let params =
|
||||
TransferSmbParams::new(bookmark.address.unwrap_or_default(), smb.share)
|
||||
.username(bookmark.username)
|
||||
.password(bookmark.password)
|
||||
.dialect(smb.dialect.unwrap_or_default());
|
||||
|
||||
Self::new(bookmark.protocol, ProtocolParams::Smb(params))
|
||||
}
|
||||
@@ -224,6 +254,7 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::filetransfer::params::{DEFAULT_GCS_ENDPOINT, SmbDialect};
|
||||
|
||||
#[test]
|
||||
fn test_bookmarks_default() {
|
||||
@@ -244,6 +275,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let recent: Bookmark = Bookmark {
|
||||
@@ -256,6 +288,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(1);
|
||||
@@ -348,6 +381,37 @@ mod tests {
|
||||
assert_eq!(s3.secret_access_key.as_deref().unwrap(), "pluto");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_convert_gcs_params_to_bookmark_and_back() {
|
||||
let transfer = FileTransferParams::new(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
ProtocolParams::GoogleCloudStorage(
|
||||
GoogleCloudStorageParams::new("archive-bucket")
|
||||
.service_account_key(Some("/keys/archive.json")),
|
||||
),
|
||||
)
|
||||
.remote_path(Some("/backups"));
|
||||
|
||||
let bookmark = Bookmark::from(transfer);
|
||||
let gcs = bookmark.gcs.as_ref().unwrap();
|
||||
assert_eq!(gcs.bucket, "archive-bucket");
|
||||
assert_eq!(gcs.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
assert_eq!(
|
||||
gcs.service_account_key.as_deref(),
|
||||
Some("/keys/archive.json")
|
||||
);
|
||||
assert_eq!(bookmark.password, None);
|
||||
|
||||
let restored = FileTransferParams::from(bookmark);
|
||||
let params = restored.params.gcs_params().unwrap();
|
||||
assert_eq!(params.bucket_name, "archive-bucket");
|
||||
assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
assert_eq!(
|
||||
params.service_account_key.as_deref(),
|
||||
Some("/keys/archive.json")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn bookmark_from_kube_ftparams() {
|
||||
let params = ProtocolParams::Kube(KubeProtocolParams {
|
||||
@@ -388,6 +452,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let params = FileTransferParams::from(bookmark);
|
||||
@@ -419,6 +484,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let params = FileTransferParams::from(bookmark);
|
||||
@@ -457,6 +523,7 @@ mod tests {
|
||||
secret_access_key: Some(String::from("pluto")),
|
||||
new_path_style: Some(true),
|
||||
}),
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let params = FileTransferParams::from(bookmark);
|
||||
@@ -497,6 +564,7 @@ mod tests {
|
||||
client_key: Some(String::from("key")),
|
||||
}),
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
};
|
||||
let params = FileTransferParams::from(bookmark);
|
||||
@@ -533,9 +601,11 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: Some(SmbParams {
|
||||
share: "test".to_string(),
|
||||
workgroup: Some("testone".to_string()),
|
||||
dialect: Some(SmbDialect::Smb2),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -556,6 +626,7 @@ mod tests {
|
||||
assert_eq!(smb_params.password.as_deref().unwrap(), "bar");
|
||||
assert_eq!(smb_params.username.as_deref().unwrap(), "foo");
|
||||
assert_eq!(smb_params.workgroup.as_deref().unwrap(), "testone");
|
||||
assert_eq!(smb_params.dialect, SmbDialect::Smb2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -571,9 +642,11 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
s3: None,
|
||||
kube: None,
|
||||
gcs: None,
|
||||
smb: Some(SmbParams {
|
||||
share: "test".to_string(),
|
||||
workgroup: None,
|
||||
dialect: Some(SmbDialect::Smb2),
|
||||
}),
|
||||
};
|
||||
|
||||
@@ -590,5 +663,32 @@ mod tests {
|
||||
let smb_params = params.params.smb_params().unwrap();
|
||||
assert_eq!(smb_params.address.as_str(), "localhost");
|
||||
assert_eq!(smb_params.share.as_str(), "test");
|
||||
assert_eq!(smb_params.dialect, SmbDialect::Smb2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_default_dialect_when_bookmark_has_none() {
|
||||
let bookmark: Bookmark = Bookmark {
|
||||
protocol: FileTransferProtocol::Smb,
|
||||
address: Some("localhost".to_string()),
|
||||
port: Some(445),
|
||||
username: None,
|
||||
password: None,
|
||||
remote_path: None,
|
||||
local_path: None,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: Some(SmbParams {
|
||||
share: "test".to_string(),
|
||||
workgroup: None,
|
||||
dialect: None,
|
||||
}),
|
||||
};
|
||||
let params = FileTransferParams::from(bookmark);
|
||||
assert_eq!(
|
||||
params.params.smb_params().unwrap().dialect,
|
||||
SmbDialect::Auto
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,65 @@
|
||||
//! ## Bookmark Google Cloud Storage Parameters
|
||||
//!
|
||||
//! Stores bookmark-specific Google Cloud Storage connection settings.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::filetransfer::params::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams};
|
||||
|
||||
fn default_gcs_endpoint() -> String {
|
||||
DEFAULT_GCS_ENDPOINT.to_string()
|
||||
}
|
||||
|
||||
/// Google Cloud Storage connection parameters stored in a bookmark.
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
|
||||
pub struct GcsParams {
|
||||
/// Bucket name to open.
|
||||
pub bucket: String,
|
||||
/// Google Cloud Storage endpoint URL.
|
||||
#[serde(default = "default_gcs_endpoint")]
|
||||
pub endpoint: String,
|
||||
/// Optional path to a service-account JSON file.
|
||||
pub service_account_key: Option<String>,
|
||||
}
|
||||
|
||||
impl From<GoogleCloudStorageParams> for GcsParams {
|
||||
fn from(params: GoogleCloudStorageParams) -> Self {
|
||||
Self {
|
||||
bucket: params.bucket_name,
|
||||
endpoint: if params.endpoint.is_empty() {
|
||||
default_gcs_endpoint()
|
||||
} else {
|
||||
params.endpoint
|
||||
},
|
||||
service_account_key: params.service_account_key,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl From<GcsParams> for GoogleCloudStorageParams {
|
||||
fn from(params: GcsParams) -> Self {
|
||||
GoogleCloudStorageParams::new(params.bucket)
|
||||
.endpoint(if params.endpoint.is_empty() {
|
||||
default_gcs_endpoint()
|
||||
} else {
|
||||
params.endpoint
|
||||
})
|
||||
.service_account_key(params.service_account_key)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_normalize_empty_endpoint() {
|
||||
let params = GoogleCloudStorageParams::from(GcsParams {
|
||||
bucket: String::from("archive-bucket"),
|
||||
endpoint: String::new(),
|
||||
service_account_key: None,
|
||||
});
|
||||
|
||||
assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
}
|
||||
}
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::filetransfer::params::SmbParams as TransferSmbParams;
|
||||
use crate::filetransfer::params::{SmbDialect, SmbParams as TransferSmbParams};
|
||||
|
||||
/// Extra Connection parameters for SMB protocol
|
||||
#[derive(Clone, Deserialize, Serialize, Debug, PartialEq, Eq, Default)]
|
||||
@@ -13,6 +13,9 @@ pub struct SmbParams {
|
||||
pub share: String,
|
||||
/// Optional SMB workgroup used on POSIX platforms.
|
||||
pub workgroup: Option<String>,
|
||||
/// Requested SMB protocol family. `None` (older bookmarks) means `Auto`.
|
||||
#[serde(default)]
|
||||
pub dialect: Option<SmbDialect>,
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
@@ -21,6 +24,7 @@ impl From<TransferSmbParams> for SmbParams {
|
||||
Self {
|
||||
share: params.share,
|
||||
workgroup: params.workgroup,
|
||||
dialect: Some(params.dialect),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -31,6 +35,46 @@ impl From<TransferSmbParams> for SmbParams {
|
||||
Self {
|
||||
share: params.share,
|
||||
workgroup: None,
|
||||
dialect: Some(params.dialect),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_missing_dialect_as_none() {
|
||||
let params: SmbParams = toml::from_str("share = \"temp\"").unwrap();
|
||||
assert_eq!(params.share.as_str(), "temp");
|
||||
assert_eq!(params.dialect, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_dialect() {
|
||||
let params: SmbParams = toml::from_str("share = \"temp\"\ndialect = \"smb1\"").unwrap();
|
||||
assert_eq!(params.dialect, Some(SmbDialect::Smb1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_round_trip_dialect() {
|
||||
let params = SmbParams {
|
||||
share: "temp".to_string(),
|
||||
workgroup: None,
|
||||
dialect: Some(SmbDialect::Smb2),
|
||||
};
|
||||
let toml_str = toml::to_string(¶ms).unwrap();
|
||||
let decoded: SmbParams = toml::from_str(&toml_str).unwrap();
|
||||
assert_eq!(decoded, params);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_convert_transfer_params_with_dialect() {
|
||||
let transfer = TransferSmbParams::new("localhost", "temp").dialect(SmbDialect::Smb3);
|
||||
let params = SmbParams::from(transfer);
|
||||
assert_eq!(params.dialect, Some(SmbDialect::Smb3));
|
||||
}
|
||||
}
|
||||
|
||||
+193
-4
@@ -118,7 +118,8 @@ mod tests {
|
||||
use crate::config::bookmarks::{Bookmark, KubeParams, S3Params, SmbParams, UserHosts};
|
||||
use crate::config::params::UserConfig;
|
||||
use crate::config::themes::Theme;
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
use crate::filetransfer::{FileTransferParams, FileTransferProtocol};
|
||||
use crate::utils::test_helpers::create_file_ioers;
|
||||
|
||||
#[test]
|
||||
@@ -200,7 +201,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/beaglebone.key")
|
||||
);
|
||||
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
|
||||
assert!(!cfg.remote.ssh_keys.contains_key("1.1.1.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -240,7 +241,7 @@ mod tests {
|
||||
.unwrap(),
|
||||
PathBuf::from("/home/omar/.ssh/beaglebone.key")
|
||||
);
|
||||
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
|
||||
assert!(!cfg.remote.ssh_keys.contains_key("1.1.1.1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -366,7 +367,7 @@ mod tests {
|
||||
assert_eq!(host.username.as_deref().unwrap(), "root");
|
||||
assert_eq!(host.password, None);
|
||||
// Verify bookmarks
|
||||
assert_eq!(hosts.bookmarks.len(), 6);
|
||||
assert_eq!(hosts.bookmarks.len(), 7);
|
||||
let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap();
|
||||
assert_eq!(host.address.as_deref().unwrap(), "192.168.1.31");
|
||||
assert_eq!(host.port.unwrap(), 22);
|
||||
@@ -404,6 +405,20 @@ mod tests {
|
||||
assert_eq!(s3.access_key.as_deref().unwrap(), "pippo");
|
||||
assert_eq!(s3.secret_access_key.as_deref().unwrap(), "pluto");
|
||||
assert_eq!(s3.new_path_style.unwrap(), true);
|
||||
// Google Cloud Storage bucket
|
||||
let host: &Bookmark = hosts.bookmarks.get("gcs-bucket").unwrap();
|
||||
assert_eq!(host.address, None);
|
||||
assert_eq!(host.port, None);
|
||||
assert_eq!(host.username, None);
|
||||
assert_eq!(host.password, None);
|
||||
assert_eq!(host.protocol, FileTransferProtocol::GoogleCloudStorage);
|
||||
let gcs = host.gcs.as_ref().unwrap();
|
||||
assert_eq!(gcs.bucket, "archive-bucket");
|
||||
assert_eq!(gcs.endpoint, "https://storage.googleapis.com");
|
||||
assert_eq!(
|
||||
gcs.service_account_key.as_deref(),
|
||||
Some("/keys/archive.json")
|
||||
);
|
||||
// Kube pod
|
||||
let host: &Bookmark = hosts.bookmarks.get("pod").unwrap();
|
||||
assert_eq!(host.address, None);
|
||||
@@ -431,6 +446,7 @@ mod tests {
|
||||
assert_eq!(smb.share.as_str(), "temp");
|
||||
#[cfg(posix)]
|
||||
assert_eq!(smb.workgroup.as_deref().unwrap(), "test");
|
||||
assert_eq!(smb.dialect, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -461,6 +477,105 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_gcs_bookmark_without_service_account_key() {
|
||||
let toml_file = create_gcs_adc_toml_bookmark();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().rewind().unwrap();
|
||||
|
||||
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
|
||||
let host = hosts.bookmarks.get("gcs-adc").unwrap();
|
||||
|
||||
assert_eq!(host.protocol, FileTransferProtocol::GoogleCloudStorage);
|
||||
let gcs = host.gcs.as_ref().unwrap();
|
||||
assert_eq!(gcs.bucket, "adc-bucket");
|
||||
assert_eq!(gcs.endpoint, "https://storage.googleapis.com");
|
||||
assert_eq!(gcs.service_account_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_legacy_smb_bookmark_without_dialect() {
|
||||
let toml_file = create_good_toml_bookmarks();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().rewind().unwrap();
|
||||
|
||||
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
|
||||
let host = hosts.bookmarks.get("smb").unwrap();
|
||||
let smb = host.smb.as_ref().unwrap();
|
||||
assert_eq!(smb.share.as_str(), "temp");
|
||||
assert_eq!(smb.dialect, None);
|
||||
|
||||
// Legacy bookmarks resolve to secure Auto at runtime.
|
||||
let params = FileTransferParams::from(host.clone());
|
||||
assert_eq!(
|
||||
params.params.smb_params().unwrap().dialect,
|
||||
SmbDialect::Auto
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_smb_bookmark_with_dialect() {
|
||||
let toml_file = create_smb_dialect_toml_bookmark();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().rewind().unwrap();
|
||||
|
||||
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
|
||||
let host = hosts.bookmarks.get("smb-dialect").unwrap();
|
||||
let smb = host.smb.as_ref().unwrap();
|
||||
assert_eq!(smb.dialect, Some(SmbDialect::Smb2));
|
||||
|
||||
let params = FileTransferParams::from(host.clone());
|
||||
assert_eq!(
|
||||
params.params.smb_params().unwrap().dialect,
|
||||
SmbDialect::Smb2
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_reserialize_legacy_smb_bookmark_and_reload() {
|
||||
let toml_file = create_good_toml_bookmarks();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().rewind().unwrap();
|
||||
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
|
||||
|
||||
let output_file = tempfile::NamedTempFile::new().unwrap();
|
||||
let output_path = output_file.path().to_path_buf();
|
||||
serialize(
|
||||
&hosts,
|
||||
Box::new(std::fs::File::create(&output_path).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let reloaded: UserHosts =
|
||||
deserialize(Box::new(std::fs::File::open(&output_path).unwrap())).unwrap();
|
||||
let smb = reloaded.bookmarks.get("smb").unwrap().smb.as_ref().unwrap();
|
||||
assert_eq!(smb.share.as_str(), "temp");
|
||||
assert_eq!(smb.dialect, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_serialize_gcs_bookmark_fields() {
|
||||
let toml_file = create_good_toml_bookmarks();
|
||||
toml_file.as_file().sync_all().unwrap();
|
||||
toml_file.as_file().rewind().unwrap();
|
||||
let hosts: UserHosts = deserialize(Box::new(toml_file)).unwrap();
|
||||
|
||||
let output_file = tempfile::NamedTempFile::new().unwrap();
|
||||
let output_path = output_file.path().to_path_buf();
|
||||
serialize(
|
||||
&hosts,
|
||||
Box::new(std::fs::File::create(&output_path).unwrap()),
|
||||
)
|
||||
.unwrap();
|
||||
let output = std::fs::read_to_string(output_path).unwrap();
|
||||
|
||||
assert!(output.contains("protocol = \"GCS\""));
|
||||
assert!(output.contains("bucket = \"archive-bucket\""));
|
||||
assert!(output.contains("endpoint = \"https://storage.googleapis.com\""));
|
||||
assert!(output.contains("service_account_key = \"/keys/archive.json\""));
|
||||
assert!(output.contains("directory = \"/backups\""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_fail_deserialize_bookmark_with_invalid_protocol() {
|
||||
let toml_file: tempfile::NamedTempFile = create_invalid_protocol_toml_bookmarks();
|
||||
@@ -486,6 +601,7 @@ mod tests {
|
||||
local_path: None,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
);
|
||||
@@ -501,6 +617,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
);
|
||||
@@ -524,6 +641,27 @@ mod tests {
|
||||
new_path_style: None,
|
||||
}),
|
||||
kube: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
);
|
||||
bookmarks.insert(
|
||||
String::from("gcs-bucket"),
|
||||
Bookmark {
|
||||
address: None,
|
||||
port: None,
|
||||
protocol: FileTransferProtocol::GoogleCloudStorage,
|
||||
username: None,
|
||||
password: None,
|
||||
remote_path: Some(PathBuf::from("/backups")),
|
||||
local_path: None,
|
||||
kube: None,
|
||||
s3: None,
|
||||
gcs: Some(crate::config::bookmarks::GcsParams {
|
||||
bucket: "archive-bucket".to_string(),
|
||||
endpoint: "https://storage.googleapis.com".to_string(),
|
||||
service_account_key: Some("/keys/archive.json".to_string()),
|
||||
}),
|
||||
smb: None,
|
||||
},
|
||||
);
|
||||
@@ -539,6 +677,7 @@ mod tests {
|
||||
remote_path: None,
|
||||
local_path: None,
|
||||
s3: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
kube: Some(KubeParams {
|
||||
namespace: Some("my-namespace".to_string()),
|
||||
@@ -553,6 +692,7 @@ mod tests {
|
||||
let smb_params: Option<SmbParams> = Some(SmbParams {
|
||||
share: "test".to_string(),
|
||||
workgroup: None,
|
||||
dialect: None,
|
||||
});
|
||||
bookmarks.insert(
|
||||
String::from("smb"),
|
||||
@@ -566,6 +706,7 @@ mod tests {
|
||||
local_path: None,
|
||||
s3: None,
|
||||
kube: None,
|
||||
gcs: None,
|
||||
smb: smb_params,
|
||||
},
|
||||
);
|
||||
@@ -582,6 +723,7 @@ mod tests {
|
||||
local_path: Some(PathBuf::from("/usr")),
|
||||
s3: None,
|
||||
kube: None,
|
||||
gcs: None,
|
||||
smb: None,
|
||||
},
|
||||
);
|
||||
@@ -656,6 +798,15 @@ mod tests {
|
||||
secret_access_key = "pluto"
|
||||
new_path_style = true
|
||||
|
||||
[bookmarks.gcs-bucket]
|
||||
protocol = "GCS"
|
||||
directory = "/backups"
|
||||
|
||||
[bookmarks.gcs-bucket.gcs]
|
||||
bucket = "archive-bucket"
|
||||
endpoint = "https://storage.googleapis.com"
|
||||
service_account_key = "/keys/archive.json"
|
||||
|
||||
[bookmarks.pod]
|
||||
protocol = "KUBE"
|
||||
[bookmarks.pod.kube]
|
||||
@@ -684,6 +835,44 @@ mod tests {
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_gcs_adc_toml_bookmark() -> tempfile::NamedTempFile {
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[bookmarks]
|
||||
|
||||
[bookmarks.gcs-adc]
|
||||
protocol = "GCS"
|
||||
|
||||
[bookmarks.gcs-adc.gcs]
|
||||
bucket = "adc-bucket"
|
||||
|
||||
[recents]
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_smb_dialect_toml_bookmark() -> tempfile::NamedTempFile {
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
[bookmarks.smb-dialect]
|
||||
protocol = "SMB"
|
||||
address = "localhost"
|
||||
port = 445
|
||||
username = "test"
|
||||
password = "test"
|
||||
|
||||
[bookmarks.smb-dialect.smb]
|
||||
share = "temp"
|
||||
workgroup = "test"
|
||||
dialect = "smb2"
|
||||
|
||||
[recents]
|
||||
"#;
|
||||
tmpfile.write_all(file_content.as_bytes()).unwrap();
|
||||
tmpfile
|
||||
}
|
||||
|
||||
fn create_v14_pod_bookmark() -> tempfile::NamedTempFile {
|
||||
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
|
||||
let file_content: &str = r#"
|
||||
|
||||
+8
-8
@@ -401,7 +401,7 @@ mod tests {
|
||||
assert_eq!(explorer.dirstack.len(), 2);
|
||||
assert_eq!(*explorer.dirstack.get(1).unwrap(), PathBuf::from("/dev"));
|
||||
assert_eq!(
|
||||
*explorer.dirstack.get(0).unwrap(),
|
||||
*explorer.dirstack.front().unwrap(),
|
||||
PathBuf::from("/home/omar")
|
||||
);
|
||||
}
|
||||
@@ -425,7 +425,7 @@ mod tests {
|
||||
assert!(explorer.get(100).is_none());
|
||||
//assert_eq!(explorer.count(), 6);
|
||||
// Verify (files are sorted by name)
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), ".git");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), ".git");
|
||||
// Iter files (all)
|
||||
assert_eq!(explorer.iter_files_all().count(), 6);
|
||||
// Iter files (hidden excluded) (.git, .gitignore are hidden)
|
||||
@@ -453,7 +453,7 @@ mod tests {
|
||||
]);
|
||||
explorer.sort_by(FileSorting::Name);
|
||||
// First entry should be "Cargo.lock"
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "Cargo.lock");
|
||||
// Last should be "src"
|
||||
assert_eq!(explorer.files.get(8).unwrap().name(), "src");
|
||||
}
|
||||
@@ -469,7 +469,7 @@ mod tests {
|
||||
explorer.set_files(vec![entry1, entry2]);
|
||||
explorer.sort_by(FileSorting::ModifyTime);
|
||||
// First entry should be "CODE_OF_CONDUCT.md"
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "CODE_OF_CONDUCT.md");
|
||||
// Last should be "src"
|
||||
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
|
||||
}
|
||||
@@ -485,7 +485,7 @@ mod tests {
|
||||
explorer.set_files(vec![entry1, entry2]);
|
||||
explorer.sort_by(FileSorting::CreationTime);
|
||||
// First entry should be "CODE_OF_CONDUCT.md"
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "CODE_OF_CONDUCT.md");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "CODE_OF_CONDUCT.md");
|
||||
// Last should be "src"
|
||||
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
|
||||
}
|
||||
@@ -501,7 +501,7 @@ mod tests {
|
||||
]);
|
||||
explorer.sort_by(FileSorting::Size);
|
||||
// Directory has size 4096
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "src");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "src");
|
||||
assert_eq!(explorer.files.get(1).unwrap().name(), "README.md");
|
||||
assert_eq!(explorer.files.get(2).unwrap().name(), "CONTRIBUTING.md");
|
||||
}
|
||||
@@ -525,7 +525,7 @@ mod tests {
|
||||
explorer.sort_by(FileSorting::Name);
|
||||
explorer.group_dirs_by(Some(GroupDirs::First));
|
||||
// First entry should be "docs"
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "docs");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "docs");
|
||||
assert_eq!(explorer.files.get(1).unwrap().name(), "src");
|
||||
// 3rd is file first for alphabetical order
|
||||
assert_eq!(explorer.files.get(2).unwrap().name(), "Cargo.lock");
|
||||
@@ -555,7 +555,7 @@ mod tests {
|
||||
assert_eq!(explorer.files.get(8).unwrap().name(), "docs");
|
||||
assert_eq!(explorer.files.get(9).unwrap().name(), "src");
|
||||
// first is file for alphabetical order
|
||||
assert_eq!(explorer.files.get(0).unwrap().name(), "Cargo.lock");
|
||||
assert_eq!(explorer.files.first().unwrap().name(), "Cargo.lock");
|
||||
// Last in files should be "README.md" (last file for alphabetical ordening)
|
||||
assert_eq!(explorer.files.get(7).unwrap().name(), "README.md");
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ pub use remotefs_builder::RemoteFsBuilder;
|
||||
pub enum FileTransferProtocol {
|
||||
AwsS3,
|
||||
Ftp(bool), // Bool is for secure (true => ftps)
|
||||
GoogleCloudStorage,
|
||||
Kube,
|
||||
Scp,
|
||||
Sftp,
|
||||
@@ -37,6 +38,7 @@ impl std::fmt::Display for FileTransferProtocol {
|
||||
true => "FTPS",
|
||||
false => "FTP",
|
||||
},
|
||||
FileTransferProtocol::GoogleCloudStorage => "GCS",
|
||||
FileTransferProtocol::Kube => "KUBE",
|
||||
FileTransferProtocol::Scp => "SCP",
|
||||
FileTransferProtocol::Sftp => "SFTP",
|
||||
@@ -53,6 +55,7 @@ impl std::str::FromStr for FileTransferProtocol {
|
||||
match s.to_ascii_uppercase().as_str() {
|
||||
"FTP" => Ok(FileTransferProtocol::Ftp(false)),
|
||||
"FTPS" => Ok(FileTransferProtocol::Ftp(true)),
|
||||
"GCS" => Ok(FileTransferProtocol::GoogleCloudStorage),
|
||||
"KUBE" => Ok(FileTransferProtocol::Kube),
|
||||
"S3" => Ok(FileTransferProtocol::AwsS3),
|
||||
"SCP" => Ok(FileTransferProtocol::Scp),
|
||||
@@ -143,6 +146,14 @@ mod tests {
|
||||
FileTransferProtocol::from_str("s3").ok().unwrap(),
|
||||
FileTransferProtocol::AwsS3
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("GCS").ok().unwrap(),
|
||||
FileTransferProtocol::GoogleCloudStorage
|
||||
);
|
||||
assert_eq!(
|
||||
FileTransferProtocol::from_str("gcs").ok().unwrap(),
|
||||
FileTransferProtocol::GoogleCloudStorage
|
||||
);
|
||||
// Error
|
||||
assert!(FileTransferProtocol::from_str("dummy").is_err());
|
||||
// To String
|
||||
@@ -161,6 +172,10 @@ mod tests {
|
||||
assert_eq!(FileTransferProtocol::Scp.to_string(), String::from("SCP"));
|
||||
assert_eq!(FileTransferProtocol::Sftp.to_string(), String::from("SFTP"));
|
||||
assert_eq!(FileTransferProtocol::AwsS3.to_string(), String::from("S3"));
|
||||
assert_eq!(
|
||||
FileTransferProtocol::GoogleCloudStorage.to_string(),
|
||||
String::from("GCS")
|
||||
);
|
||||
assert_eq!(FileTransferProtocol::Smb.to_string(), String::from("SMB"));
|
||||
assert_eq!(
|
||||
FileTransferProtocol::WebDAV.to_string(),
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
//! file transfer parameters
|
||||
|
||||
mod aws_s3;
|
||||
mod google_cloud_storage;
|
||||
mod kube;
|
||||
mod smb;
|
||||
mod webdav;
|
||||
@@ -10,8 +11,9 @@ mod webdav;
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
pub use self::aws_s3::AwsS3Params;
|
||||
pub use self::google_cloud_storage::{DEFAULT_GCS_ENDPOINT, GoogleCloudStorageParams};
|
||||
pub use self::kube::KubeProtocolParams;
|
||||
pub use self::smb::SmbParams;
|
||||
pub use self::smb::{SmbDialect, SmbParams};
|
||||
pub use self::webdav::WebDAVProtocolParams;
|
||||
use super::FileTransferProtocol;
|
||||
|
||||
@@ -66,6 +68,7 @@ impl FileTransferParams {
|
||||
pub enum ProtocolParams {
|
||||
Generic(GenericProtocolParams),
|
||||
AwsS3(AwsS3Params),
|
||||
GoogleCloudStorage(GoogleCloudStorageParams),
|
||||
Kube(KubeProtocolParams),
|
||||
Smb(SmbParams),
|
||||
WebDAV(WebDAVProtocolParams),
|
||||
@@ -76,6 +79,7 @@ impl ProtocolParams {
|
||||
match self {
|
||||
ProtocolParams::AwsS3(params) => params.password_missing(),
|
||||
ProtocolParams::Generic(params) => params.password_missing(),
|
||||
ProtocolParams::GoogleCloudStorage(params) => params.password_missing(),
|
||||
ProtocolParams::Kube(params) => params.password_missing(),
|
||||
ProtocolParams::Smb(params) => params.password_missing(),
|
||||
ProtocolParams::WebDAV(params) => params.password_missing(),
|
||||
@@ -87,6 +91,7 @@ impl ProtocolParams {
|
||||
match self {
|
||||
ProtocolParams::AwsS3(params) => params.set_default_secret(secret),
|
||||
ProtocolParams::Generic(params) => params.set_default_secret(secret),
|
||||
ProtocolParams::GoogleCloudStorage(params) => params.set_default_secret(secret),
|
||||
ProtocolParams::Kube(params) => params.set_default_secret(secret),
|
||||
ProtocolParams::Smb(params) => params.set_default_secret(secret),
|
||||
ProtocolParams::WebDAV(params) => params.set_default_secret(secret),
|
||||
@@ -97,6 +102,7 @@ impl ProtocolParams {
|
||||
match self {
|
||||
ProtocolParams::AwsS3(params) => params.bucket_name.clone(),
|
||||
ProtocolParams::Generic(params) => params.address.clone(),
|
||||
ProtocolParams::GoogleCloudStorage(params) => params.bucket_name.clone(),
|
||||
ProtocolParams::Kube(params) => params
|
||||
.namespace
|
||||
.as_ref()
|
||||
@@ -193,6 +199,15 @@ impl ProtocolParams {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Retrieve Google Cloud Storage parameters if any.
|
||||
pub fn gcs_params(&self) -> Option<&GoogleCloudStorageParams> {
|
||||
match self {
|
||||
ProtocolParams::GoogleCloudStorage(params) => Some(params),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
/// Retrieve Kube params parameters if any
|
||||
pub fn kube_params(&self) -> Option<&KubeProtocolParams> {
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! ## Google Cloud Storage Parameters
|
||||
//!
|
||||
//! Defines the runtime connection parameters used to build Google Cloud
|
||||
//! Storage clients.
|
||||
|
||||
/// Google Cloud Storage's default JSON API endpoint.
|
||||
pub const DEFAULT_GCS_ENDPOINT: &str = "https://storage.googleapis.com";
|
||||
|
||||
/// Connection parameters for Google Cloud Storage.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct GoogleCloudStorageParams {
|
||||
/// Target bucket name.
|
||||
pub bucket_name: String,
|
||||
/// Google Cloud Storage endpoint URL.
|
||||
pub endpoint: String,
|
||||
/// Optional path to a service-account JSON file.
|
||||
pub service_account_key: Option<String>,
|
||||
}
|
||||
|
||||
impl GoogleCloudStorageParams {
|
||||
/// Creates Google Cloud Storage parameters using the default endpoint.
|
||||
pub fn new<S: Into<String>>(bucket_name: S) -> Self {
|
||||
Self {
|
||||
bucket_name: bucket_name.into(),
|
||||
endpoint: DEFAULT_GCS_ENDPOINT.to_string(),
|
||||
service_account_key: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Sets the Google Cloud Storage endpoint.
|
||||
pub fn endpoint<S: Into<String>>(mut self, endpoint: S) -> Self {
|
||||
self.endpoint = endpoint.into();
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the optional service-account JSON file path.
|
||||
pub fn service_account_key<S: Into<String>>(mut self, path: Option<S>) -> Self {
|
||||
self.service_account_key = path.map(Into::into);
|
||||
self
|
||||
}
|
||||
|
||||
/// Reports whether the protocol's default secret is missing.
|
||||
pub fn password_missing(&self) -> bool {
|
||||
false
|
||||
}
|
||||
|
||||
/// Ignores generic password secrets because GCS uses ADC or a credential file.
|
||||
pub fn set_default_secret(&mut self, _secret: String) {}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_use_google_storage_default_endpoint() {
|
||||
let params = GoogleCloudStorageParams::new("my-bucket");
|
||||
|
||||
assert_eq!(params.bucket_name, "my-bucket");
|
||||
assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
assert_eq!(params.service_account_key, None);
|
||||
assert!(!params.password_missing());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_override_endpoint_and_credentials_path() {
|
||||
let params = GoogleCloudStorageParams::new("my-bucket")
|
||||
.endpoint("http://127.0.0.1:4443")
|
||||
.service_account_key(Some("credentials.json"));
|
||||
|
||||
assert_eq!(params.endpoint, "http://127.0.0.1:4443");
|
||||
assert_eq!(
|
||||
params.service_account_key.as_deref(),
|
||||
Some("credentials.json")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,26 @@
|
||||
//! Defines the runtime connection parameters used to build SMB remote
|
||||
//! filesystem clients.
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
/// SMB protocol family requested for a connection.
|
||||
///
|
||||
/// Each family maps to inclusive dialect bounds when the Unix client is built.
|
||||
/// `Auto` negotiates SMB2 or SMB3 and never falls back to SMB1.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum SmbDialect {
|
||||
/// Negotiate SMB 2.0.2 through SMB 3.1.1.
|
||||
#[default]
|
||||
Auto,
|
||||
/// Force the deprecated NT1 (CIFS) dialect.
|
||||
Smb1,
|
||||
/// Negotiate SMB 2.0.2 through SMB 2.1.
|
||||
Smb2,
|
||||
/// Negotiate SMB 3.0 through SMB 3.1.1.
|
||||
Smb3,
|
||||
}
|
||||
|
||||
/// Connection parameters for SMB protocol
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SmbParams {
|
||||
@@ -20,6 +40,8 @@ pub struct SmbParams {
|
||||
#[cfg(posix)]
|
||||
/// Optional workgroup used on POSIX platforms.
|
||||
pub workgroup: Option<String>,
|
||||
/// Requested SMB protocol family. Enforced on POSIX platforms only.
|
||||
pub dialect: SmbDialect,
|
||||
}
|
||||
|
||||
// -- SMB params
|
||||
@@ -36,6 +58,7 @@ impl SmbParams {
|
||||
password: None,
|
||||
#[cfg(posix)]
|
||||
workgroup: None,
|
||||
dialect: SmbDialect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,6 +84,12 @@ impl SmbParams {
|
||||
self
|
||||
}
|
||||
|
||||
/// Sets the SMB protocol family to request.
|
||||
pub fn dialect(mut self, dialect: SmbDialect) -> Self {
|
||||
self.dialect = dialect;
|
||||
self
|
||||
}
|
||||
|
||||
/// Returns whether a password is supposed to be required for this protocol params.
|
||||
/// The result true is returned ONLY if the supposed secret is MISSING!!!
|
||||
pub fn password_missing(&self) -> bool {
|
||||
@@ -82,7 +111,8 @@ mod test {
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use super::SmbParams;
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
|
||||
#[test]
|
||||
fn should_init_smb_params() {
|
||||
@@ -118,6 +148,67 @@ mod test {
|
||||
assert_eq!(params.workgroup.as_deref().unwrap(), "baz");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_default_dialect_to_auto() {
|
||||
assert_eq!(SmbDialect::default(), SmbDialect::Auto);
|
||||
let params = SmbParams::new("localhost", "temp");
|
||||
assert_eq!(params.dialect, SmbDialect::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_set_dialect() {
|
||||
let params = SmbParams::new("localhost", "temp").dialect(SmbDialect::Smb1);
|
||||
assert_eq!(params.dialect, SmbDialect::Smb1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_serialize_dialect_lowercase() {
|
||||
assert_eq!(
|
||||
toml::to_string(&Wrapper {
|
||||
dialect: SmbDialect::Auto,
|
||||
})
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"dialect = \"auto\""
|
||||
);
|
||||
assert_eq!(
|
||||
toml::to_string(&Wrapper {
|
||||
dialect: SmbDialect::Smb1,
|
||||
})
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"dialect = \"smb1\""
|
||||
);
|
||||
assert_eq!(
|
||||
toml::to_string(&Wrapper {
|
||||
dialect: SmbDialect::Smb2,
|
||||
})
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"dialect = \"smb2\""
|
||||
);
|
||||
assert_eq!(
|
||||
toml::to_string(&Wrapper {
|
||||
dialect: SmbDialect::Smb3,
|
||||
})
|
||||
.unwrap()
|
||||
.trim(),
|
||||
"dialect = \"smb3\""
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_deserialize_dialect_lowercase() {
|
||||
let w: Wrapper = toml::from_str("dialect = \"smb3\"").unwrap();
|
||||
assert_eq!(w.dialect, SmbDialect::Smb3);
|
||||
assert!(toml::from_str::<Wrapper>("dialect = \"SMB3\"").is_err());
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize)]
|
||||
struct Wrapper {
|
||||
dialect: SmbDialect,
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(win)]
|
||||
fn should_init_smb_params_with_optionals() {
|
||||
|
||||
@@ -8,26 +8,31 @@ use std::sync::Arc;
|
||||
use remotefs::RemoteFs;
|
||||
use remotefs_aws_s3::AwsS3Fs;
|
||||
use remotefs_ftp::FtpFs;
|
||||
use remotefs_gcs::credentials::service_account;
|
||||
use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs};
|
||||
use remotefs_kube::KubeMultiPodFs as KubeFs;
|
||||
#[cfg(smb_unix)]
|
||||
use remotefs_smb::SmbOptions;
|
||||
#[cfg(smb)]
|
||||
use remotefs_smb::{SmbCredentials, SmbFs};
|
||||
use remotefs_smb::{PavaoSmbCredentials as SmbCredentials, PavaoSmbFs as SmbFs};
|
||||
#[cfg(smb_unix)]
|
||||
use remotefs_smb::{PavaoSmbOptions as SmbOptions, SmbDialect as RemoteSmbDialect};
|
||||
#[cfg(smb_windows)]
|
||||
use remotefs_smb::{WNetSmbCredentials as SmbCredentials, WNetSmbFs as SmbFs};
|
||||
use remotefs_ssh::{
|
||||
NoCheckServerKey, RusshSession as SshSession, ScpFs, SftpFs, SshAgentIdentity,
|
||||
SshConfigParseRule, SshOpts,
|
||||
};
|
||||
use remotefs_webdav::WebDAVFs;
|
||||
|
||||
#[cfg(smb_unix)]
|
||||
use super::params::SmbDialect;
|
||||
#[cfg(not(smb))]
|
||||
use super::params::{AwsS3Params, GenericProtocolParams};
|
||||
use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams};
|
||||
#[cfg(smb)]
|
||||
use super::params::{AwsS3Params, GenericProtocolParams, SmbParams};
|
||||
use super::params::{AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, SmbParams};
|
||||
use super::params::{KubeProtocolParams, WebDAVProtocolParams};
|
||||
use super::{FileTransferProtocol, ProtocolParams};
|
||||
use crate::system::config_client::ConfigClient;
|
||||
use crate::system::sshkey_storage::SshKeyStorage;
|
||||
use crate::utils::ssh as ssh_utils;
|
||||
|
||||
/// Remotefs builder
|
||||
pub struct RemoteFsBuilder;
|
||||
@@ -48,6 +53,10 @@ impl RemoteFsBuilder {
|
||||
(FileTransferProtocol::Ftp(secure), ProtocolParams::Generic(params)) => {
|
||||
Ok(Box::new(Self::ftp_client(params, secure)))
|
||||
}
|
||||
(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
ProtocolParams::GoogleCloudStorage(params),
|
||||
) => Ok(Box::new(Self::gcs_client(params)?)),
|
||||
(FileTransferProtocol::Kube, ProtocolParams::Kube(params)) => {
|
||||
Ok(Box::new(Self::kube_client(params)?))
|
||||
}
|
||||
@@ -108,6 +117,36 @@ impl RemoteFsBuilder {
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Build a Google Cloud Storage client from parameters.
|
||||
fn gcs_client(params: GoogleCloudStorageParams) -> Result<GoogleCloudStorageFs, String> {
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let mut client = match params.service_account_key {
|
||||
None => GoogleCloudStorageFs::new(params.bucket_name, &runtime),
|
||||
Some(path) => {
|
||||
let raw = std::fs::read_to_string(&path).map_err(|error| {
|
||||
format!("Unable to read GCS service-account file '{path}': {error}")
|
||||
})?;
|
||||
let key = serde_json::from_str(&raw).map_err(|error| {
|
||||
format!("Invalid GCS service-account JSON in '{path}': {error}")
|
||||
})?;
|
||||
let credentials = {
|
||||
let _guard = runtime.enter();
|
||||
service_account::Builder::new(key).build()
|
||||
}
|
||||
.map_err(|error| {
|
||||
format!("Invalid GCS service-account credentials in '{path}': {error}")
|
||||
})?;
|
||||
GoogleCloudStorageFs::with_credentials(
|
||||
params.bucket_name,
|
||||
GoogleCloudStorageCredentials::custom(credentials),
|
||||
&runtime,
|
||||
)
|
||||
}
|
||||
};
|
||||
client = client.endpoint(params.endpoint);
|
||||
Ok(client)
|
||||
}
|
||||
|
||||
/// Build ftp client from parameters
|
||||
fn ftp_client(params: GenericProtocolParams, secure: bool) -> FtpFs {
|
||||
let mut client = FtpFs::new(params.address, params.port).passive_mode();
|
||||
@@ -154,6 +193,17 @@ impl RemoteFsBuilder {
|
||||
Ok(SftpFs::russh(opts, rt))
|
||||
}
|
||||
|
||||
/// Maps the user-facing SMB family to inclusive remotefs dialect bounds.
|
||||
#[cfg(smb_unix)]
|
||||
fn smb_dialect_bounds(dialect: SmbDialect) -> (RemoteSmbDialect, RemoteSmbDialect) {
|
||||
match dialect {
|
||||
SmbDialect::Auto => (RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb311),
|
||||
SmbDialect::Smb1 => (RemoteSmbDialect::Nt1, RemoteSmbDialect::Nt1),
|
||||
SmbDialect::Smb2 => (RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb210),
|
||||
SmbDialect::Smb3 => (RemoteSmbDialect::Smb300, RemoteSmbDialect::Smb311),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(smb_unix)]
|
||||
fn smb_client(params: SmbParams) -> Result<SmbFs, String> {
|
||||
let mut credentials = SmbCredentials::default()
|
||||
@@ -170,11 +220,14 @@ impl RemoteFsBuilder {
|
||||
credentials = credentials.workgroup(workgroup);
|
||||
}
|
||||
|
||||
SmbFs::try_new(
|
||||
let (min_dialect, max_dialect) = Self::smb_dialect_bounds(params.dialect);
|
||||
SmbFs::try_new_with_dialect(
|
||||
credentials,
|
||||
SmbOptions::default()
|
||||
.one_share_per_server(true)
|
||||
.case_sensitive(false),
|
||||
min_dialect,
|
||||
max_dialect,
|
||||
)
|
||||
.map_err(|e| {
|
||||
error!("Invalid params for protocol SMB: {e}");
|
||||
@@ -193,6 +246,7 @@ impl RemoteFsBuilder {
|
||||
credentials = credentials.password(password);
|
||||
}
|
||||
|
||||
// Dialect is OS-managed on Windows.
|
||||
Ok(SmbFs::new(credentials))
|
||||
}
|
||||
|
||||
@@ -206,37 +260,9 @@ impl RemoteFsBuilder {
|
||||
.key_storage(Box::new(Self::make_ssh_storage(config_client)))
|
||||
.ssh_agent_identity(Some(SshAgentIdentity::All))
|
||||
.port(params.port);
|
||||
// get ssh config
|
||||
let ssh_config = config_client
|
||||
.get_ssh_config()
|
||||
.and_then(|path| {
|
||||
debug!("reading ssh config at {}", path);
|
||||
ssh_utils::parse_ssh2_config(path).ok()
|
||||
})
|
||||
.map(|config| config.query(¶ms.address));
|
||||
|
||||
//* override port
|
||||
if let Some(port) = ssh_config.as_ref().and_then(|config| config.port) {
|
||||
opts = opts.port(port);
|
||||
}
|
||||
|
||||
//* get username. Case 1 provided in params
|
||||
if let Some(username) = params.username {
|
||||
opts = opts.username(username);
|
||||
} else if let Some(ssh_config) = &ssh_config {
|
||||
debug!("no username was provided, checking whether a user is set for this host");
|
||||
if let Some(username) = &ssh_config.user {
|
||||
debug!("found username from config: {username}");
|
||||
opts = opts.username(username);
|
||||
} else {
|
||||
//* case 3: use system username; can't be None
|
||||
debug!("no username was provided, using current username");
|
||||
if let Ok(username) = whoami::username() {
|
||||
opts = opts.username(username);
|
||||
}
|
||||
}
|
||||
} else if let Ok(username) = whoami::username() {
|
||||
debug!("no username was provided, using current username");
|
||||
opts = opts.username(username);
|
||||
}
|
||||
// For SSH protocols, only set password if explicitly provided and non-empty.
|
||||
@@ -277,6 +303,8 @@ mod test {
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(smb)]
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
@@ -298,6 +326,62 @@ mod test {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_gcs_fs_with_application_default_credentials() {
|
||||
let params = ProtocolParams::GoogleCloudStorage(GoogleCloudStorageParams::new("my-bucket"));
|
||||
let config_client = get_config_client();
|
||||
|
||||
assert!(
|
||||
RemoteFsBuilder::build(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
params,
|
||||
&config_client,
|
||||
)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_reject_missing_gcs_service_account_file() {
|
||||
let directory = tempfile::TempDir::new().unwrap();
|
||||
let missing = directory.path().join("missing.json");
|
||||
let params = GoogleCloudStorageParams::new("my-bucket")
|
||||
.service_account_key(Some(missing.to_string_lossy().into_owned()));
|
||||
|
||||
assert!(RemoteFsBuilder::gcs_client(params).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_reject_malformed_gcs_service_account_json() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(file.path(), "not-json").unwrap();
|
||||
let params = GoogleCloudStorageParams::new("my-bucket")
|
||||
.service_account_key(Some(file.path().to_string_lossy().into_owned()));
|
||||
|
||||
assert!(RemoteFsBuilder::gcs_client(params).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_gcs_fs_with_service_account_file() {
|
||||
let file = tempfile::NamedTempFile::new().unwrap();
|
||||
std::fs::write(
|
||||
file.path(),
|
||||
r#"{
|
||||
"type": "service_account",
|
||||
"client_email": "termscp@example.iam.gserviceaccount.com",
|
||||
"private_key_id": "test-key",
|
||||
"private_key": "-----BEGIN PRIVATE KEY-----\ninvalid-test-key\n-----END PRIVATE KEY-----\n",
|
||||
"project_id": "termscp-test",
|
||||
"universe_domain": "googleapis.com"
|
||||
}"#,
|
||||
)
|
||||
.unwrap();
|
||||
let params = GoogleCloudStorageParams::new("my-bucket")
|
||||
.service_account_key(Some(file.path().to_string_lossy().into_owned()));
|
||||
|
||||
assert!(RemoteFsBuilder::gcs_client(params).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_build_ftp_fs() {
|
||||
let params = ProtocolParams::Generic(
|
||||
@@ -354,12 +438,50 @@ mod test {
|
||||
|
||||
#[test]
|
||||
#[cfg(smb)]
|
||||
#[serial]
|
||||
fn should_build_smb_fs() {
|
||||
let params = ProtocolParams::Smb(SmbParams::new("localhost", "share"));
|
||||
let config_client = get_config_client();
|
||||
assert!(RemoteFsBuilder::build(FileTransferProtocol::Smb, params, &config_client).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(smb_unix)]
|
||||
fn should_map_smb_dialect_to_bounds() {
|
||||
use remotefs_smb::SmbDialect as RemoteSmbDialect;
|
||||
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
|
||||
assert_eq!(
|
||||
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Auto),
|
||||
(RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb311)
|
||||
);
|
||||
assert_eq!(
|
||||
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb1),
|
||||
(RemoteSmbDialect::Nt1, RemoteSmbDialect::Nt1)
|
||||
);
|
||||
assert_eq!(
|
||||
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb2),
|
||||
(RemoteSmbDialect::Smb202, RemoteSmbDialect::Smb210)
|
||||
);
|
||||
assert_eq!(
|
||||
RemoteFsBuilder::smb_dialect_bounds(SmbDialect::Smb3),
|
||||
(RemoteSmbDialect::Smb300, RemoteSmbDialect::Smb311)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(smb)]
|
||||
#[serial]
|
||||
fn should_build_smb_fs_with_dialect() {
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
|
||||
let params =
|
||||
ProtocolParams::Smb(SmbParams::new("localhost", "share").dialect(SmbDialect::Smb1));
|
||||
let config_client = get_config_client();
|
||||
assert!(RemoteFsBuilder::build(FileTransferProtocol::Smb, params, &config_client).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_not_build_fs() {
|
||||
let params = ProtocolParams::Generic(
|
||||
|
||||
@@ -36,6 +36,8 @@ pub enum HostErrorType {
|
||||
ExecutionFailed,
|
||||
#[error("Could not delete file")]
|
||||
DeleteFailed,
|
||||
#[error("Invalid SSH configuration: {0}")]
|
||||
InvalidSshConfig(String),
|
||||
#[cfg(win)]
|
||||
#[error("Not implemented")]
|
||||
NotImplemented,
|
||||
|
||||
+17
-15
@@ -602,9 +602,11 @@ mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::utils::test_helpers::create_sample_file;
|
||||
#[cfg(posix)]
|
||||
use crate::utils::test_helpers::make_file_at;
|
||||
#[cfg(posix)]
|
||||
use crate::utils::test_helpers::make_fsentry;
|
||||
use crate::utils::test_helpers::{create_sample_file, make_file_at};
|
||||
|
||||
#[test]
|
||||
fn test_host_error_new() {
|
||||
@@ -632,13 +634,13 @@ mod tests {
|
||||
#[test]
|
||||
#[cfg(win)]
|
||||
fn test_host_localhost_new() {
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from("C:\\users")).ok().unwrap();
|
||||
let host: Localhost = Localhost::new(PathBuf::from("C:\\users")).ok().unwrap();
|
||||
assert_eq!(host.wrkdir, PathBuf::from("C:\\users"));
|
||||
// Scan dir
|
||||
let entries = std::fs::read_dir(PathBuf::from("C:\\users").as_path()).unwrap();
|
||||
let mut counter: usize = 0;
|
||||
for _ in entries {
|
||||
counter = counter + 1;
|
||||
counter += 1;
|
||||
}
|
||||
assert_eq!(host.files.len(), counter);
|
||||
}
|
||||
@@ -769,7 +771,7 @@ mod tests {
|
||||
let host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let files: Vec<File> = host.files.clone();
|
||||
// Verify files
|
||||
let file_0: &File = files.get(0).unwrap();
|
||||
let file_0: &File = files.first().unwrap();
|
||||
if file_0.name() == *"foo.txt" {
|
||||
assert!(file_0.metadata.symlink.is_none());
|
||||
} else {
|
||||
@@ -827,7 +829,7 @@ mod tests {
|
||||
let files: Vec<File> = host.files.clone();
|
||||
assert_eq!(files.len(), 1); // There should be 1 file now
|
||||
// Remove file
|
||||
assert!(host.remove(files.get(0).unwrap()).is_ok());
|
||||
assert!(host.remove(files.first().unwrap()).is_ok());
|
||||
// There should be 0 files now
|
||||
let files: Vec<File> = host.files.clone();
|
||||
assert_eq!(files.len(), 0); // There should be 0 files now
|
||||
@@ -836,7 +838,7 @@ mod tests {
|
||||
// Delete directory
|
||||
let files: Vec<File> = host.files.clone();
|
||||
assert_eq!(files.len(), 1); // There should be 1 file now
|
||||
assert!(host.remove(files.get(0).unwrap()).is_ok());
|
||||
assert!(host.remove(files.first().unwrap()).is_ok());
|
||||
// Remove unexisting directory
|
||||
assert!(
|
||||
host.remove(&make_fsentry(PathBuf::from("/a/b/c/d"), true))
|
||||
@@ -859,22 +861,22 @@ mod tests {
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let files: Vec<File> = host.files.clone();
|
||||
assert_eq!(files.len(), 1); // There should be 1 file now
|
||||
assert_eq!(files.get(0).unwrap().name(), "foo.txt");
|
||||
assert_eq!(files.first().unwrap().name(), "foo.txt");
|
||||
// Rename file
|
||||
let dst_path: PathBuf =
|
||||
PathBuf::from(format!("{}/bar.txt", tmpdir.path().display()).as_str());
|
||||
assert!(
|
||||
host.rename(files.get(0).unwrap(), dst_path.as_path())
|
||||
host.rename(files.first().unwrap(), dst_path.as_path())
|
||||
.is_ok()
|
||||
);
|
||||
// There should be still 1 file now, but named bar.txt
|
||||
let files: Vec<File> = host.files.clone();
|
||||
assert_eq!(files.len(), 1); // There should be 0 files now
|
||||
assert_eq!(files.get(0).unwrap().name(), "bar.txt");
|
||||
assert_eq!(files.first().unwrap().name(), "bar.txt");
|
||||
// Fail
|
||||
let bad_path: PathBuf = PathBuf::from("/asdailsjoidoewojdijow/ashdiuahu");
|
||||
assert!(
|
||||
host.rename(files.get(0).unwrap(), bad_path.as_path())
|
||||
host.rename(files.first().unwrap(), bad_path.as_path())
|
||||
.is_err()
|
||||
);
|
||||
}
|
||||
@@ -939,7 +941,7 @@ mod tests {
|
||||
file2_path.push("bar.txt");
|
||||
// Create host
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let file1_entry: File = host.files.get(0).unwrap().clone();
|
||||
let file1_entry: File = host.files.first().unwrap().clone();
|
||||
assert_eq!(file1_entry.name(), String::from("foo.txt"));
|
||||
// Copy
|
||||
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
|
||||
@@ -969,7 +971,7 @@ mod tests {
|
||||
let file2_path: PathBuf = PathBuf::from("bar.txt");
|
||||
// Create host
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let file1_entry: File = host.files.get(0).unwrap().clone();
|
||||
let file1_entry: File = host.files.first().unwrap().clone();
|
||||
assert_eq!(file1_entry.name(), String::from("foo.txt"));
|
||||
// Copy
|
||||
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
|
||||
@@ -989,7 +991,7 @@ mod tests {
|
||||
assert!(file1.write_all(b"Hello world!\n").is_ok());
|
||||
// Create host
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let file1_entry: File = host.files.get(0).unwrap().clone();
|
||||
let file1_entry: File = host.files.first().unwrap().clone();
|
||||
assert_eq!(file1_entry.name(), String::from("foo.txt"));
|
||||
// Copy with empty destination -> must fail and leave file untouched
|
||||
assert!(
|
||||
@@ -1022,7 +1024,7 @@ mod tests {
|
||||
dir_dest.push("test_dest_dir/");
|
||||
// Create host
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let dir_src_entry: File = host.files.get(0).unwrap().clone();
|
||||
let dir_src_entry: File = host.files.first().unwrap().clone();
|
||||
assert_eq!(dir_src_entry.name(), String::from("test_dir"));
|
||||
// Copy
|
||||
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
|
||||
@@ -1052,7 +1054,7 @@ mod tests {
|
||||
let dir_dest: PathBuf = PathBuf::from("test_dest_dir/");
|
||||
// Create host
|
||||
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
|
||||
let dir_src_entry: File = host.files.get(0).unwrap().clone();
|
||||
let dir_src_entry: File = host.files.first().unwrap().clone();
|
||||
assert_eq!(dir_src_entry.name(), String::from("test_dir"));
|
||||
// Copy
|
||||
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
|
||||
|
||||
+1
-7
@@ -88,13 +88,7 @@ fn parse_args(args: Args) -> Result<RunOpts, String> {
|
||||
// Match ticks
|
||||
run_opts.ticks = Duration::from_millis(args.ticks);
|
||||
// Remote argument
|
||||
match RemoteArgs::try_from(&args) {
|
||||
Err(err) => return Err(err),
|
||||
Ok(remote) => {
|
||||
// Set params
|
||||
run_opts.remote = remote;
|
||||
}
|
||||
}
|
||||
run_opts.remote = RemoteArgs::try_from(&args)?;
|
||||
|
||||
// set activity based on remote state
|
||||
run_opts.task = if run_opts.remote.remote.is_none() {
|
||||
|
||||
@@ -68,7 +68,8 @@ impl Update {
|
||||
}
|
||||
|
||||
/// Returns whether a new version of termscp is available
|
||||
/// In case of success returns Ok(Option<Release>), where the Option is Some(new_version);
|
||||
/// In case of success returns `Ok(Option<Release>)`, where the option is
|
||||
/// `Some(new_version)`;
|
||||
/// otherwise if no version is available, return None
|
||||
/// In case of error returns Error with the error description
|
||||
pub fn is_new_version_available() -> Result<Option<Release>, UpdateError> {
|
||||
|
||||
@@ -379,7 +379,9 @@ mod tests {
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
use crate::filetransfer::params::{AwsS3Params, GenericProtocolParams};
|
||||
use crate::filetransfer::params::{
|
||||
AwsS3Params, DEFAULT_GCS_ENDPOINT, GenericProtocolParams, GoogleCloudStorageParams,
|
||||
};
|
||||
use crate::filetransfer::{FileTransferProtocol, ProtocolParams};
|
||||
|
||||
#[test]
|
||||
@@ -526,6 +528,58 @@ mod tests {
|
||||
assert_eq!(params.secret_access_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_preserve_gcs_service_account_path_for_saved_bookmarks() {
|
||||
for save_password in [true, false] {
|
||||
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let mut client =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
|
||||
|
||||
client
|
||||
.add_bookmark(
|
||||
"gcs-bucket",
|
||||
make_gcs_ftparams(Some("/keys/archive.json")),
|
||||
save_password,
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
let bookmark = client.get_bookmark("gcs-bucket").unwrap();
|
||||
let params = bookmark.params.gcs_params().unwrap();
|
||||
assert_eq!(params.bucket_name, "archive-bucket");
|
||||
assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
assert_eq!(
|
||||
params.service_account_key.as_deref(),
|
||||
Some("/keys/archive.json")
|
||||
);
|
||||
assert_eq!(bookmark.password_missing(), false);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_make_gcs_recent_without_password() {
|
||||
let tmp_dir: tempfile::TempDir = TempDir::new().ok().unwrap();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let mut client =
|
||||
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16, true).unwrap();
|
||||
|
||||
client
|
||||
.add_recent(make_gcs_ftparams(Some("/keys/archive.json")).remote_path(Some("/backups")))
|
||||
.unwrap();
|
||||
|
||||
let recent_key = client.iter_recents().next().unwrap().clone();
|
||||
let recent = client.get_recent(&recent_key).unwrap();
|
||||
let params = recent.params.gcs_params().unwrap();
|
||||
assert_eq!(params.bucket_name, "archive-bucket");
|
||||
assert_eq!(params.endpoint, DEFAULT_GCS_ENDPOINT);
|
||||
assert_eq!(
|
||||
params.service_account_key.as_deref(),
|
||||
Some("/keys/archive.json")
|
||||
);
|
||||
assert_eq!(recent.password_missing(), false);
|
||||
assert_eq!(recent.remote_path.as_deref(), Some(Path::new("/backups")));
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
fn test_system_bookmarks_manipulate_bookmarks() {
|
||||
@@ -767,7 +821,7 @@ mod tests {
|
||||
// Limit is 2
|
||||
assert_eq!(client.iter_recents().count(), 2);
|
||||
// Check that 192.168.1.1 has been removed
|
||||
let key: String = client.iter_recents().next().unwrap().to_string();
|
||||
let key: String = client.iter_recents().next().unwrap().clone();
|
||||
assert!(matches!(
|
||||
client
|
||||
.hosts
|
||||
@@ -781,7 +835,7 @@ mod tests {
|
||||
.as_str(),
|
||||
"192.168.1.2" | "192.168.1.3"
|
||||
));
|
||||
let key: String = client.iter_recents().nth(1).unwrap().to_string();
|
||||
let key: String = client.iter_recents().nth(1).unwrap().clone();
|
||||
assert!(matches!(
|
||||
client
|
||||
.hosts
|
||||
@@ -932,13 +986,23 @@ mod tests {
|
||||
)
|
||||
}
|
||||
|
||||
fn make_gcs_ftparams(service_account_key: Option<&str>) -> FileTransferParams {
|
||||
FileTransferParams::new(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
ProtocolParams::GoogleCloudStorage(
|
||||
GoogleCloudStorageParams::new("archive-bucket")
|
||||
.service_account_key(service_account_key),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fn ftparams_to_tup(
|
||||
params: FileTransferParams,
|
||||
) -> (String, u16, FileTransferProtocol, String, Option<String>) {
|
||||
let protocol = params.protocol;
|
||||
let p = params.params.generic_params().unwrap();
|
||||
(
|
||||
p.address.to_string(),
|
||||
p.address.clone(),
|
||||
p.port,
|
||||
protocol,
|
||||
p.username.as_ref().cloned().unwrap_or_default(),
|
||||
|
||||
@@ -532,6 +532,23 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_round_trip_gcs_as_default_protocol() {
|
||||
let tmp_dir: TempDir = TempDir::new().ok().unwrap();
|
||||
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
|
||||
let mut client = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
|
||||
client.set_default_protocol(FileTransferProtocol::GoogleCloudStorage);
|
||||
|
||||
assert_eq!(
|
||||
client.get_default_protocol(),
|
||||
FileTransferProtocol::GoogleCloudStorage
|
||||
);
|
||||
assert_eq!(client.config.user_interface.default_protocol, "GCS");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_system_config_show_hidden_files() {
|
||||
let tmp_dir: TempDir = TempDir::new().ok().unwrap();
|
||||
@@ -595,7 +612,7 @@ mod tests {
|
||||
client.set_local_file_fmt(String::from("{NAME}"));
|
||||
assert_eq!(client.get_local_file_fmt().unwrap(), String::from("{NAME}"));
|
||||
// Delete
|
||||
client.set_local_file_fmt(String::from(""));
|
||||
client.set_local_file_fmt(String::new());
|
||||
assert_eq!(client.get_local_file_fmt(), None);
|
||||
}
|
||||
|
||||
@@ -613,7 +630,7 @@ mod tests {
|
||||
String::from("{NAME}")
|
||||
);
|
||||
// Delete
|
||||
client.set_remote_file_fmt(String::from(""));
|
||||
client.set_remote_file_fmt(String::new());
|
||||
assert_eq!(client.get_remote_file_fmt(), None);
|
||||
}
|
||||
|
||||
|
||||
@@ -207,6 +207,7 @@ mod tests {
|
||||
let mut f: File = OpenOptions::new()
|
||||
.create(true)
|
||||
.write(true)
|
||||
.truncate(true)
|
||||
.open(conf_dir.as_path())
|
||||
.ok()
|
||||
.unwrap();
|
||||
|
||||
@@ -294,7 +294,7 @@ mod test {
|
||||
);
|
||||
// unwatch
|
||||
assert!(watcher.unwatch(tempdir.path()).is_ok());
|
||||
assert!(watcher.paths.get(tempdir.path()).is_none());
|
||||
assert!(!watcher.paths.contains_key(tempdir.path()));
|
||||
// close tempdir
|
||||
assert!(tempdir.close().is_ok());
|
||||
}
|
||||
@@ -315,7 +315,7 @@ mod test {
|
||||
watcher.unwatch(subdir.as_path()).unwrap().as_path(),
|
||||
Path::new(tempdir.path())
|
||||
);
|
||||
assert!(watcher.paths.get(tempdir.path()).is_none());
|
||||
assert!(!watcher.paths.contains_key(tempdir.path()));
|
||||
// close tempdir
|
||||
assert!(tempdir.close().is_ok());
|
||||
}
|
||||
|
||||
+137
-6
@@ -31,9 +31,10 @@ const HOST_BRIDGE_RADIO_PROTOCOL_SCP: usize = 2;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_FTP: usize = 3;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_FTPS: usize = 4;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_S3: usize = 5;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_KUBE: usize = 6;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV: usize = 7;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_SMB: usize = 8; // Keep as last
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_GCS: usize = 6;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_KUBE: usize = 7;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV: usize = 8;
|
||||
const HOST_BRIDGE_RADIO_PROTOCOL_SMB: usize = 9; // Keep as last
|
||||
|
||||
// remote protocol radio
|
||||
const REMOTE_RADIO_PROTOCOL_SFTP: usize = 0;
|
||||
@@ -41,9 +42,10 @@ const REMOTE_RADIO_PROTOCOL_SCP: usize = 1;
|
||||
const REMOTE_RADIO_PROTOCOL_FTP: usize = 2;
|
||||
const REMOTE_RADIO_PROTOCOL_FTPS: usize = 3;
|
||||
const REMOTE_RADIO_PROTOCOL_S3: usize = 4;
|
||||
const REMOTE_RADIO_PROTOCOL_KUBE: usize = 5;
|
||||
const REMOTE_RADIO_PROTOCOL_WEBDAV: usize = 6;
|
||||
const REMOTE_RADIO_PROTOCOL_SMB: usize = 7; // Keep as last
|
||||
const REMOTE_RADIO_PROTOCOL_GCS: usize = 5;
|
||||
const REMOTE_RADIO_PROTOCOL_KUBE: usize = 6;
|
||||
const REMOTE_RADIO_PROTOCOL_WEBDAV: usize = 7;
|
||||
const REMOTE_RADIO_PROTOCOL_SMB: usize = 8; // Keep as last
|
||||
|
||||
// -- components
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
|
||||
@@ -74,6 +76,9 @@ pub enum Id {
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
|
||||
pub enum AuthFormId {
|
||||
Address,
|
||||
GcsBucket,
|
||||
GcsEndpoint,
|
||||
GcsServiceAccountKey,
|
||||
KubeNamespace,
|
||||
KubeClusterUrl,
|
||||
KubeUsername,
|
||||
@@ -96,6 +101,10 @@ pub enum AuthFormId {
|
||||
SmbShare,
|
||||
#[cfg(posix)]
|
||||
SmbWorkgroup,
|
||||
#[cfg(posix)]
|
||||
SmbDialect,
|
||||
#[cfg(posix)]
|
||||
SmbDialectWarning,
|
||||
Username,
|
||||
WebDAVUri,
|
||||
}
|
||||
@@ -153,6 +162,12 @@ pub enum UiAuthFormMsg {
|
||||
AddressBlurDown,
|
||||
AddressBlurUp,
|
||||
ChangeFormTab,
|
||||
GcsBucketBlurDown,
|
||||
GcsBucketBlurUp,
|
||||
GcsEndpointBlurDown,
|
||||
GcsEndpointBlurUp,
|
||||
GcsServiceAccountKeyBlurDown,
|
||||
GcsServiceAccountKeyBlurUp,
|
||||
KubeNamespaceBlurDown,
|
||||
KubeNamespaceBlurUp,
|
||||
KubeClusterUrlBlurDown,
|
||||
@@ -198,6 +213,10 @@ pub enum UiAuthFormMsg {
|
||||
SmbWorkgroupDown,
|
||||
#[cfg(posix)]
|
||||
SmbWorkgroupUp,
|
||||
#[cfg(posix)]
|
||||
SmbDialectBlurDown,
|
||||
#[cfg(posix)]
|
||||
SmbDialectBlurUp,
|
||||
UsernameBlurDown,
|
||||
UsernameBlurUp,
|
||||
WebDAVUriBlurDown,
|
||||
@@ -209,6 +228,7 @@ pub enum UiAuthFormMsg {
|
||||
enum InputMask {
|
||||
Generic,
|
||||
AwsS3,
|
||||
Gcs,
|
||||
Kube,
|
||||
Localhost,
|
||||
Smb,
|
||||
@@ -231,6 +251,18 @@ enum FormTab {
|
||||
const STORE_KEY_LATEST_VERSION: &str = "AUTH_LATEST_VERSION";
|
||||
const STORE_KEY_RELEASE_NOTES: &str = "AUTH_RELEASE_NOTES";
|
||||
|
||||
fn should_resolve_ssh_host_params(
|
||||
protocol: FileTransferProtocol,
|
||||
mounted_address: &str,
|
||||
address: &str,
|
||||
force: bool,
|
||||
) -> bool {
|
||||
matches!(
|
||||
protocol,
|
||||
FileTransferProtocol::Scp | FileTransferProtocol::Sftp
|
||||
) && (force || mounted_address != address)
|
||||
}
|
||||
|
||||
/// AuthActivity is the data holder for the authentication activity
|
||||
pub struct AuthActivity {
|
||||
app: Application<Id, Msg, NoUserEvent>,
|
||||
@@ -244,7 +276,11 @@ pub struct AuthActivity {
|
||||
redraw: bool,
|
||||
/// Host bridge protocol
|
||||
host_bridge_protocol: HostBridgeProtocol,
|
||||
/// Last Host address applied to the Host Bridge form.
|
||||
last_host_bridge_address: String,
|
||||
last_form_tab: FormTab,
|
||||
/// Last Host address applied to the Remote form.
|
||||
last_remote_address: String,
|
||||
/// Remote file transfer protocol
|
||||
remote_protocol: FileTransferProtocol,
|
||||
context: Option<Context>,
|
||||
@@ -261,6 +297,8 @@ impl AuthActivity {
|
||||
bookmarks_list: Vec::new(),
|
||||
exit_reason: None,
|
||||
last_form_tab: FormTab::Remote,
|
||||
last_host_bridge_address: String::new(),
|
||||
last_remote_address: String::new(),
|
||||
recents_list: Vec::new(),
|
||||
redraw: true,
|
||||
host_bridge_protocol: HostBridgeProtocol::Localhost,
|
||||
@@ -301,6 +339,24 @@ impl AuthActivity {
|
||||
Self::file_transfer_protocol_input_mask(self.remote_protocol)
|
||||
}
|
||||
|
||||
fn set_remote_protocol(&mut self, protocol: FileTransferProtocol) {
|
||||
self.remote_protocol = protocol;
|
||||
}
|
||||
|
||||
fn last_mounted_address(&self, form_tab: FormTab) -> &str {
|
||||
match form_tab {
|
||||
FormTab::HostBridge => self.last_host_bridge_address.as_str(),
|
||||
FormTab::Remote => self.last_remote_address.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
fn set_last_mounted_address(&mut self, form_tab: FormTab, address: &str) {
|
||||
match form_tab {
|
||||
FormTab::HostBridge => self.last_host_bridge_address = address.to_string(),
|
||||
FormTab::Remote => self.last_remote_address = address.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Get current input mask to show
|
||||
fn host_bridge_input_mask(&self) -> InputMask {
|
||||
match self.host_bridge_protocol {
|
||||
@@ -315,6 +371,7 @@ impl AuthActivity {
|
||||
fn file_transfer_protocol_input_mask(protocol: FileTransferProtocol) -> InputMask {
|
||||
match protocol {
|
||||
FileTransferProtocol::AwsS3 => InputMask::AwsS3,
|
||||
FileTransferProtocol::GoogleCloudStorage => InputMask::Gcs,
|
||||
FileTransferProtocol::Ftp(_)
|
||||
| FileTransferProtocol::Scp
|
||||
| FileTransferProtocol::Sftp => InputMask::Generic,
|
||||
@@ -411,3 +468,77 @@ impl Activity for AuthActivity {
|
||||
self.context.take()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn should_set_configured_remote_protocol() {
|
||||
let mut activity = AuthActivity::new(Duration::ZERO);
|
||||
|
||||
activity.set_remote_protocol(FileTransferProtocol::GoogleCloudStorage);
|
||||
|
||||
assert_eq!(
|
||||
activity.remote_protocol,
|
||||
FileTransferProtocol::GoogleCloudStorage
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_resolve_ssh_params_only_after_host_change_or_forced_ssh_transition() {
|
||||
assert!(should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Sftp,
|
||||
"saved-host",
|
||||
"edited-host",
|
||||
false
|
||||
));
|
||||
assert!(!should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Sftp,
|
||||
"saved-host",
|
||||
"saved-host",
|
||||
false
|
||||
));
|
||||
assert!(should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Scp,
|
||||
"saved-host",
|
||||
"saved-host",
|
||||
true
|
||||
));
|
||||
assert!(!should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Ftp(false),
|
||||
"saved-host",
|
||||
"edited-host",
|
||||
true
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_track_host_bridge_and_remote_addresses_independently() {
|
||||
let mut activity = AuthActivity::new(Duration::ZERO);
|
||||
|
||||
activity.set_last_mounted_address(FormTab::HostBridge, "bookmark-host");
|
||||
activity.set_last_mounted_address(FormTab::Remote, "recent-host");
|
||||
|
||||
assert_eq!(
|
||||
activity.last_mounted_address(FormTab::HostBridge),
|
||||
"bookmark-host"
|
||||
);
|
||||
assert_eq!(
|
||||
activity.last_mounted_address(FormTab::Remote),
|
||||
"recent-host"
|
||||
);
|
||||
assert!(!should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Sftp,
|
||||
activity.last_mounted_address(FormTab::Remote),
|
||||
"recent-host",
|
||||
false
|
||||
));
|
||||
assert!(should_resolve_ssh_host_params(
|
||||
FileTransferProtocol::Sftp,
|
||||
activity.last_mounted_address(FormTab::HostBridge),
|
||||
"edited-host",
|
||||
false
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,8 +6,8 @@
|
||||
use super::{AuthActivity, FileTransferParams, FormTab, HostBridgeProtocol};
|
||||
use crate::filetransfer::HostBridgeParams;
|
||||
use crate::filetransfer::params::{
|
||||
AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, SmbParams,
|
||||
WebDAVProtocolParams,
|
||||
AwsS3Params, DEFAULT_GCS_ENDPOINT, GenericProtocolParams, GoogleCloudStorageParams,
|
||||
KubeProtocolParams, ProtocolParams, SmbParams, WebDAVProtocolParams,
|
||||
};
|
||||
|
||||
impl AuthActivity {
|
||||
@@ -201,6 +201,9 @@ impl AuthActivity {
|
||||
ProtocolParams::AwsS3(params) => {
|
||||
self.load_bookmark_s3_into_gui(FormTab::HostBridge, params)
|
||||
}
|
||||
ProtocolParams::GoogleCloudStorage(params) => {
|
||||
self.load_bookmark_gcs_into_gui(FormTab::HostBridge, params)
|
||||
}
|
||||
ProtocolParams::Kube(params) => {
|
||||
self.load_bookmark_kube_into_gui(FormTab::HostBridge, params)
|
||||
}
|
||||
@@ -240,6 +243,9 @@ impl AuthActivity {
|
||||
ProtocolParams::AwsS3(params) => {
|
||||
self.load_bookmark_s3_into_gui(FormTab::Remote, params)
|
||||
}
|
||||
ProtocolParams::GoogleCloudStorage(params) => {
|
||||
self.load_bookmark_gcs_into_gui(FormTab::Remote, params)
|
||||
}
|
||||
ProtocolParams::Kube(params) => {
|
||||
self.load_bookmark_kube_into_gui(FormTab::Remote, params)
|
||||
}
|
||||
@@ -276,6 +282,22 @@ impl AuthActivity {
|
||||
self.mount_s3_new_path_style(form_tab, params.new_path_style);
|
||||
}
|
||||
|
||||
fn load_bookmark_gcs_into_gui(&mut self, form_tab: FormTab, params: GoogleCloudStorageParams) {
|
||||
self.mount_gcs_bucket(form_tab, ¶ms.bucket_name);
|
||||
self.mount_gcs_endpoint(
|
||||
form_tab,
|
||||
if params.endpoint.is_empty() {
|
||||
DEFAULT_GCS_ENDPOINT
|
||||
} else {
|
||||
¶ms.endpoint
|
||||
},
|
||||
);
|
||||
self.mount_gcs_service_account_key(
|
||||
form_tab,
|
||||
params.service_account_key.as_deref().unwrap_or(""),
|
||||
);
|
||||
}
|
||||
|
||||
fn load_bookmark_kube_into_gui(&mut self, form_tab: FormTab, params: KubeProtocolParams) {
|
||||
self.mount_kube_cluster_url(form_tab, params.cluster_url.as_deref().unwrap_or(""));
|
||||
self.mount_kube_namespace(form_tab, params.namespace.as_deref().unwrap_or(""));
|
||||
@@ -293,6 +315,8 @@ impl AuthActivity {
|
||||
self.mount_smb_share(form_tab, ¶ms.share);
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_workgroup(form_tab, params.workgroup.as_deref().unwrap_or(""));
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_dialect(form_tab, params.dialect);
|
||||
}
|
||||
|
||||
fn load_bookmark_webdav_into_gui(&mut self, form_tab: FormTab, params: WebDAVProtocolParams) {
|
||||
|
||||
@@ -13,16 +13,16 @@ pub use bookmarks::{
|
||||
BookmarkName, BookmarkSavePassword, BookmarksList, DeleteBookmarkPopup, DeleteRecentPopup,
|
||||
RecentsList,
|
||||
};
|
||||
#[cfg(posix)]
|
||||
pub use form::InputSmbWorkgroup;
|
||||
pub use form::{
|
||||
HostBridgeProtocolRadio, InputAddress, InputKubeClientCert, InputKubeClientKey,
|
||||
InputKubeClusterUrl, InputKubeNamespace, InputKubeUsername, InputLocalDirectory, InputPassword,
|
||||
InputPort, InputRemoteDirectory, InputS3AccessKey, InputS3Bucket, InputS3Endpoint,
|
||||
InputS3Profile, InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken,
|
||||
InputS3SessionToken, InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle,
|
||||
RemoteProtocolRadio,
|
||||
HostBridgeProtocolRadio, InputAddress, InputGcsBucket, InputGcsEndpoint,
|
||||
InputGcsServiceAccountKey, InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl,
|
||||
InputKubeNamespace, InputKubeUsername, InputLocalDirectory, InputPassword, InputPort,
|
||||
InputRemoteDirectory, InputS3AccessKey, InputS3Bucket, InputS3Endpoint, InputS3Profile,
|
||||
InputS3Region, InputS3SecretAccessKey, InputS3SecurityToken, InputS3SessionToken,
|
||||
InputSmbShare, InputUsername, InputWebDAVUri, RadioS3NewPathStyle, RemoteProtocolRadio,
|
||||
};
|
||||
#[cfg(posix)]
|
||||
pub use form::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
|
||||
pub use popup::{
|
||||
ErrorPopup, InfoPopup, InstallUpdatePopup, Keybindings, QuitPopup, ReleaseNotes, WaitPopup,
|
||||
WindowSizeError,
|
||||
|
||||
@@ -13,14 +13,18 @@ use tuirealm::props::{
|
||||
use super::{FileTransferProtocol, FormMsg, Msg, UiMsg};
|
||||
use crate::ui::activities::auth::{
|
||||
FormTab, HOST_BRIDGE_RADIO_PROTOCOL_FTP, HOST_BRIDGE_RADIO_PROTOCOL_FTPS,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_KUBE, HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_S3, HOST_BRIDGE_RADIO_PROTOCOL_SCP, HOST_BRIDGE_RADIO_PROTOCOL_SFTP,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_GCS, HOST_BRIDGE_RADIO_PROTOCOL_KUBE,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_LOCALHOST, HOST_BRIDGE_RADIO_PROTOCOL_S3,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_SCP, HOST_BRIDGE_RADIO_PROTOCOL_SFTP,
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_SMB, HOST_BRIDGE_RADIO_PROTOCOL_WEBDAV, HostBridgeProtocol,
|
||||
REMOTE_RADIO_PROTOCOL_FTP, REMOTE_RADIO_PROTOCOL_FTPS, REMOTE_RADIO_PROTOCOL_KUBE,
|
||||
REMOTE_RADIO_PROTOCOL_S3, REMOTE_RADIO_PROTOCOL_SCP, REMOTE_RADIO_PROTOCOL_SFTP,
|
||||
REMOTE_RADIO_PROTOCOL_SMB, REMOTE_RADIO_PROTOCOL_WEBDAV, UiAuthFormMsg,
|
||||
REMOTE_RADIO_PROTOCOL_FTP, REMOTE_RADIO_PROTOCOL_FTPS, REMOTE_RADIO_PROTOCOL_GCS,
|
||||
REMOTE_RADIO_PROTOCOL_KUBE, REMOTE_RADIO_PROTOCOL_S3, REMOTE_RADIO_PROTOCOL_SCP,
|
||||
REMOTE_RADIO_PROTOCOL_SFTP, REMOTE_RADIO_PROTOCOL_SMB, REMOTE_RADIO_PROTOCOL_WEBDAV,
|
||||
UiAuthFormMsg,
|
||||
};
|
||||
|
||||
#[path = "form/gcs.rs"]
|
||||
mod gcs;
|
||||
#[path = "form/generic.rs"]
|
||||
mod generic;
|
||||
#[path = "form/kube.rs"]
|
||||
@@ -36,6 +40,7 @@ mod smb;
|
||||
#[path = "form/webdav.rs"]
|
||||
mod webdav;
|
||||
|
||||
pub use gcs::{InputGcsBucket, InputGcsEndpoint, InputGcsServiceAccountKey};
|
||||
pub use generic::{InputAddress, InputPassword, InputPort, InputUsername};
|
||||
pub use kube::{
|
||||
InputKubeClientCert, InputKubeClientKey, InputKubeClusterUrl, InputKubeNamespace,
|
||||
@@ -49,7 +54,7 @@ pub use s3::{
|
||||
};
|
||||
pub use smb::InputSmbShare;
|
||||
#[cfg(posix)]
|
||||
pub use smb::InputSmbWorkgroup;
|
||||
pub use smb::{InputSmbWorkgroup, RadioSmbDialect, SmbDialectWarning};
|
||||
pub use webdav::InputWebDAVUri;
|
||||
|
||||
fn handle_input_ev(
|
||||
|
||||
@@ -0,0 +1,144 @@
|
||||
//! ## Google Cloud Storage Form
|
||||
//!
|
||||
//! Input components for Google Cloud Storage authentication parameters.
|
||||
|
||||
use tuirealm::component::{AppComponent, Component};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct InputGcsBucket {
|
||||
component: Input,
|
||||
form_tab: FormTab,
|
||||
}
|
||||
|
||||
impl InputGcsBucket {
|
||||
pub fn new(bucket: &str, form_tab: FormTab, color: Color) -> Self {
|
||||
Self {
|
||||
component: Input::default()
|
||||
.borders(
|
||||
Borders::default()
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.placeholder(tuirealm::props::SpanStatic::styled(
|
||||
"my-bucket",
|
||||
Style::default().fg(Color::Rgb(128, 128, 128)),
|
||||
))
|
||||
.title(Title::from("Bucket").alignment(HorizontalAlignment::Left))
|
||||
.input_type(InputType::Text)
|
||||
.value(bucket),
|
||||
form_tab,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppComponent<Msg, NoUserEvent> for InputGcsBucket {
|
||||
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
let (on_key_down, on_key_up) = match self.form_tab {
|
||||
FormTab::Remote => (
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsBucketBlurDown)),
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsBucketBlurUp)),
|
||||
),
|
||||
FormTab::HostBridge => (
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsBucketBlurDown)),
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsBucketBlurUp)),
|
||||
),
|
||||
};
|
||||
let form_tab = self.form_tab;
|
||||
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct InputGcsEndpoint {
|
||||
component: Input,
|
||||
form_tab: FormTab,
|
||||
}
|
||||
|
||||
impl InputGcsEndpoint {
|
||||
pub fn new(endpoint: &str, form_tab: FormTab, color: Color) -> Self {
|
||||
Self {
|
||||
component: Input::default()
|
||||
.borders(
|
||||
Borders::default()
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.placeholder(tuirealm::props::SpanStatic::styled(
|
||||
"https://storage.googleapis.com",
|
||||
Style::default().fg(Color::Rgb(128, 128, 128)),
|
||||
))
|
||||
.title(Title::from("Endpoint").alignment(HorizontalAlignment::Left))
|
||||
.input_type(InputType::Text)
|
||||
.value(endpoint),
|
||||
form_tab,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppComponent<Msg, NoUserEvent> for InputGcsEndpoint {
|
||||
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
let (on_key_down, on_key_up) = match self.form_tab {
|
||||
FormTab::Remote => (
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsEndpointBlurDown)),
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsEndpointBlurUp)),
|
||||
),
|
||||
FormTab::HostBridge => (
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsEndpointBlurDown)),
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsEndpointBlurUp)),
|
||||
),
|
||||
};
|
||||
let form_tab = self.form_tab;
|
||||
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct InputGcsServiceAccountKey {
|
||||
component: Input,
|
||||
form_tab: FormTab,
|
||||
}
|
||||
|
||||
impl InputGcsServiceAccountKey {
|
||||
pub fn new(path: &str, form_tab: FormTab, color: Color) -> Self {
|
||||
Self {
|
||||
component: Input::default()
|
||||
.borders(
|
||||
Borders::default()
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.foreground(color)
|
||||
.placeholder(tuirealm::props::SpanStatic::styled(
|
||||
"Optional service-account JSON path",
|
||||
Style::default().fg(Color::Rgb(128, 128, 128)),
|
||||
))
|
||||
.title(Title::from("Service account JSON").alignment(HorizontalAlignment::Left))
|
||||
.input_type(InputType::Text)
|
||||
.value(path),
|
||||
form_tab,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl AppComponent<Msg, NoUserEvent> for InputGcsServiceAccountKey {
|
||||
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
let (on_key_down, on_key_up) = match self.form_tab {
|
||||
FormTab::Remote => (
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsServiceAccountKeyBlurDown)),
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::GcsServiceAccountKeyBlurUp)),
|
||||
),
|
||||
FormTab::HostBridge => (
|
||||
Msg::Ui(UiMsg::HostBridge(
|
||||
UiAuthFormMsg::GcsServiceAccountKeyBlurDown,
|
||||
)),
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::GcsServiceAccountKeyBlurUp)),
|
||||
),
|
||||
};
|
||||
let form_tab = self.form_tab;
|
||||
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
|
||||
}
|
||||
}
|
||||
@@ -28,9 +28,12 @@ impl RemoteProtocolRadio {
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(if cfg!(smb) {
|
||||
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV", "SMB"].into_iter()
|
||||
vec![
|
||||
"SFTP", "SCP", "FTP", "FTPS", "S3", "GCS", "Kube", "WebDAV", "SMB",
|
||||
]
|
||||
.into_iter()
|
||||
} else {
|
||||
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "Kube", "WebDAV"].into_iter()
|
||||
vec!["SFTP", "SCP", "FTP", "FTPS", "S3", "GCS", "Kube", "WebDAV"].into_iter()
|
||||
})
|
||||
.rewind(true)
|
||||
.title(Title::from("Protocol").alignment(HorizontalAlignment::Left))
|
||||
@@ -44,6 +47,7 @@ impl RemoteProtocolRadio {
|
||||
REMOTE_RADIO_PROTOCOL_FTP => FileTransferProtocol::Ftp(false),
|
||||
REMOTE_RADIO_PROTOCOL_FTPS => FileTransferProtocol::Ftp(true),
|
||||
REMOTE_RADIO_PROTOCOL_S3 => FileTransferProtocol::AwsS3,
|
||||
REMOTE_RADIO_PROTOCOL_GCS => FileTransferProtocol::GoogleCloudStorage,
|
||||
REMOTE_RADIO_PROTOCOL_SMB => FileTransferProtocol::Smb,
|
||||
REMOTE_RADIO_PROTOCOL_KUBE => FileTransferProtocol::Kube,
|
||||
REMOTE_RADIO_PROTOCOL_WEBDAV => FileTransferProtocol::WebDAV,
|
||||
@@ -58,6 +62,7 @@ impl RemoteProtocolRadio {
|
||||
FileTransferProtocol::Ftp(false) => REMOTE_RADIO_PROTOCOL_FTP,
|
||||
FileTransferProtocol::Ftp(true) => REMOTE_RADIO_PROTOCOL_FTPS,
|
||||
FileTransferProtocol::AwsS3 => REMOTE_RADIO_PROTOCOL_S3,
|
||||
FileTransferProtocol::GoogleCloudStorage => REMOTE_RADIO_PROTOCOL_GCS,
|
||||
FileTransferProtocol::Kube => REMOTE_RADIO_PROTOCOL_KUBE,
|
||||
FileTransferProtocol::Smb => REMOTE_RADIO_PROTOCOL_SMB,
|
||||
FileTransferProtocol::WebDAV => REMOTE_RADIO_PROTOCOL_WEBDAV,
|
||||
@@ -128,6 +133,7 @@ impl HostBridgeProtocolRadio {
|
||||
"FTP",
|
||||
"FTPS",
|
||||
"S3",
|
||||
"GCS",
|
||||
"Kube",
|
||||
"WebDAV",
|
||||
"SMB",
|
||||
@@ -141,6 +147,7 @@ impl HostBridgeProtocolRadio {
|
||||
"FTP",
|
||||
"FTPS",
|
||||
"S3",
|
||||
"GCS",
|
||||
"Kube",
|
||||
"WebDAV",
|
||||
]
|
||||
@@ -168,6 +175,9 @@ impl HostBridgeProtocolRadio {
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3) => {
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_S3
|
||||
}
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::GoogleCloudStorage) => {
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_GCS
|
||||
}
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::Smb) => HOST_BRIDGE_RADIO_PROTOCOL_SMB,
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::Kube) => {
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_KUBE
|
||||
@@ -194,6 +204,9 @@ impl HostBridgeProtocolRadio {
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_S3 => {
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::AwsS3)
|
||||
}
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_GCS => {
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::GoogleCloudStorage)
|
||||
}
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_SMB => HostBridgeProtocol::Remote(FileTransferProtocol::Smb),
|
||||
HOST_BRIDGE_RADIO_PROTOCOL_KUBE => {
|
||||
HostBridgeProtocol::Remote(FileTransferProtocol::Kube)
|
||||
|
||||
@@ -1,7 +1,13 @@
|
||||
#[cfg(posix)]
|
||||
use tui_realm_stdlib::components::Span;
|
||||
use tuirealm::component::{AppComponent, Component};
|
||||
use tuirealm::event::NoUserEvent;
|
||||
#[cfg(posix)]
|
||||
use tuirealm::props::SpanStatic;
|
||||
|
||||
use super::*;
|
||||
#[cfg(posix)]
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
|
||||
#[derive(Component)]
|
||||
pub struct InputSmbShare {
|
||||
@@ -85,3 +91,163 @@ impl AppComponent<Msg, NoUserEvent> for InputSmbWorkgroup {
|
||||
handle_input_ev(self, ev, on_key_down, on_key_up, form_tab)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, posix))]
|
||||
mod test {
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
use super::*;
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
|
||||
#[test]
|
||||
fn should_map_radio_options_to_dialect() {
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(0), SmbDialect::Auto);
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(1), SmbDialect::Smb1);
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(2), SmbDialect::Smb2);
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(3), SmbDialect::Smb3);
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(99), SmbDialect::Auto);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_map_dialect_to_radio_options() {
|
||||
for dialect in [
|
||||
SmbDialect::Auto,
|
||||
SmbDialect::Smb1,
|
||||
SmbDialect::Smb2,
|
||||
SmbDialect::Smb3,
|
||||
] {
|
||||
let opt = RadioSmbDialect::dialect_to_opt(dialect);
|
||||
assert_eq!(RadioSmbDialect::opt_to_dialect(opt), dialect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
const RADIO_SMB_DIALECT_AUTO: usize = 0;
|
||||
#[cfg(posix)]
|
||||
const RADIO_SMB_DIALECT_SMB1: usize = 1;
|
||||
#[cfg(posix)]
|
||||
const RADIO_SMB_DIALECT_SMB2: usize = 2;
|
||||
#[cfg(posix)]
|
||||
const RADIO_SMB_DIALECT_SMB3: usize = 3;
|
||||
|
||||
/// Radio to select the SMB protocol family.
|
||||
#[cfg(posix)]
|
||||
#[derive(Component)]
|
||||
pub struct RadioSmbDialect {
|
||||
component: Radio,
|
||||
form_tab: FormTab,
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
impl RadioSmbDialect {
|
||||
pub fn new(dialect: SmbDialect, form_tab: FormTab, color: Color) -> Self {
|
||||
Self {
|
||||
component: Radio::default()
|
||||
.highlight_style(
|
||||
Style::default()
|
||||
.fg(color)
|
||||
.add_modifier(TextModifiers::REVERSED),
|
||||
)
|
||||
.borders(
|
||||
Borders::default()
|
||||
.color(color)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(["Auto", "SMB1 (insecure)", "SMB2", "SMB3"])
|
||||
.rewind(true)
|
||||
.title(Title::from("SMB version").alignment(HorizontalAlignment::Left))
|
||||
.value(Self::dialect_to_opt(dialect)),
|
||||
form_tab,
|
||||
}
|
||||
}
|
||||
|
||||
/// Converts the radio choice index to a dialect. Unknown indexes map to `Auto`.
|
||||
pub fn opt_to_dialect(opt: usize) -> SmbDialect {
|
||||
match opt {
|
||||
RADIO_SMB_DIALECT_SMB1 => SmbDialect::Smb1,
|
||||
RADIO_SMB_DIALECT_SMB2 => SmbDialect::Smb2,
|
||||
RADIO_SMB_DIALECT_SMB3 => SmbDialect::Smb3,
|
||||
_ => SmbDialect::Auto,
|
||||
}
|
||||
}
|
||||
|
||||
fn dialect_to_opt(dialect: SmbDialect) -> usize {
|
||||
match dialect {
|
||||
SmbDialect::Auto => RADIO_SMB_DIALECT_AUTO,
|
||||
SmbDialect::Smb1 => RADIO_SMB_DIALECT_SMB1,
|
||||
SmbDialect::Smb2 => RADIO_SMB_DIALECT_SMB2,
|
||||
SmbDialect::Smb3 => RADIO_SMB_DIALECT_SMB3,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
impl AppComponent<Msg, NoUserEvent> for RadioSmbDialect {
|
||||
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
match ev {
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Left, ..
|
||||
}) => {
|
||||
self.perform(Cmd::Move(Direction::Left));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Right, ..
|
||||
}) => {
|
||||
self.perform(Cmd::Move(Direction::Right));
|
||||
Some(Msg::None)
|
||||
}
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Enter, ..
|
||||
}) => Some(Msg::Form(FormMsg::Connect)),
|
||||
Event::Keyboard(KeyEvent {
|
||||
code: Key::Down, ..
|
||||
}) => Some(if self.form_tab == FormTab::Remote {
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbDialectBlurDown))
|
||||
} else {
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbDialectBlurDown))
|
||||
}),
|
||||
Event::Keyboard(KeyEvent { code: Key::Up, .. }) => {
|
||||
Some(if self.form_tab == FormTab::Remote {
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::SmbDialectBlurUp))
|
||||
} else {
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::SmbDialectBlurUp))
|
||||
})
|
||||
}
|
||||
Event::Keyboard(KeyEvent { code: Key::Tab, .. }) => {
|
||||
Some(if self.form_tab == FormTab::Remote {
|
||||
Msg::Ui(UiMsg::Remote(UiAuthFormMsg::ParamsFormBlur))
|
||||
} else {
|
||||
Msg::Ui(UiMsg::HostBridge(UiAuthFormMsg::ParamsFormBlur))
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One-line warning shown above the dialect radio while SMB1 is selected.
|
||||
#[cfg(posix)]
|
||||
#[derive(Component)]
|
||||
pub struct SmbDialectWarning {
|
||||
component: Span,
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
impl SmbDialectWarning {
|
||||
pub fn new(color: Color) -> Self {
|
||||
Self {
|
||||
component: Span::default().foreground(color).spans([SpanStatic::from(
|
||||
"Warning: SMB1 is deprecated and insecure. Use it only for isolated legacy devices.",
|
||||
)]),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
impl AppComponent<Msg, NoUserEvent> for SmbDialectWarning {
|
||||
fn on(&mut self, _ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@ impl AuthActivity {
|
||||
FileTransferProtocol::Sftp | FileTransferProtocol::Scp => 22,
|
||||
FileTransferProtocol::Ftp(_) => 21,
|
||||
FileTransferProtocol::AwsS3 => 22, // Doesn't matter, since not used
|
||||
FileTransferProtocol::GoogleCloudStorage => 22, // Doesn't matter, since not used
|
||||
FileTransferProtocol::Kube => 22, // Doesn't matter, since not used
|
||||
FileTransferProtocol::Smb => 445,
|
||||
FileTransferProtocol::WebDAV => 80, // Doesn't matter, since not used
|
||||
@@ -45,6 +46,9 @@ impl AuthActivity {
|
||||
HostBridgeProtocol::Remote(remote) => {
|
||||
let transfer_params = match remote {
|
||||
FileTransferProtocol::AwsS3 => self.collect_s3_host_params(FormTab::HostBridge),
|
||||
FileTransferProtocol::GoogleCloudStorage => {
|
||||
self.collect_gcs_host_params(FormTab::HostBridge)
|
||||
}
|
||||
FileTransferProtocol::Kube => {
|
||||
self.collect_kube_host_params(FormTab::HostBridge)
|
||||
}
|
||||
@@ -71,6 +75,9 @@ impl AuthActivity {
|
||||
pub(super) fn collect_remote_host_params(&self) -> Result<FileTransferParams, &'static str> {
|
||||
match self.remote_protocol {
|
||||
FileTransferProtocol::AwsS3 => self.collect_s3_host_params(FormTab::Remote),
|
||||
FileTransferProtocol::GoogleCloudStorage => {
|
||||
self.collect_gcs_host_params(FormTab::Remote)
|
||||
}
|
||||
FileTransferProtocol::Kube => self.collect_kube_host_params(FormTab::Remote),
|
||||
FileTransferProtocol::Smb => self.collect_smb_host_params(FormTab::Remote),
|
||||
FileTransferProtocol::Ftp(_)
|
||||
@@ -136,6 +143,26 @@ impl AuthActivity {
|
||||
})
|
||||
}
|
||||
|
||||
/// Get input values from fields or return an error if fields are invalid to work as GCS.
|
||||
pub(super) fn collect_gcs_host_params(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> Result<FileTransferParams, &'static str> {
|
||||
let params = self.get_gcs_params_input(form_tab);
|
||||
if params.bucket_name.is_empty() {
|
||||
return Err("Invalid bucket");
|
||||
}
|
||||
if params.endpoint.is_empty() {
|
||||
return Err("Invalid endpoint");
|
||||
}
|
||||
Ok(FileTransferParams {
|
||||
protocol: FileTransferProtocol::GoogleCloudStorage,
|
||||
params: ProtocolParams::GoogleCloudStorage(params),
|
||||
local_path: self.get_input_local_directory(form_tab),
|
||||
remote_path: self.get_input_remote_directory(form_tab),
|
||||
})
|
||||
}
|
||||
|
||||
/// Get input values from fields or return an error if fields are invalid to work as aws s3
|
||||
pub(super) fn collect_kube_host_params(
|
||||
&self,
|
||||
|
||||
@@ -6,8 +6,10 @@ use tuirealm::state::{State, StateValue};
|
||||
|
||||
use super::{
|
||||
AuthActivity, AuthFormId, ExitReason, FormMsg, FormTab, HostBridgeProtocol, Id, InputMask, Msg,
|
||||
UiAuthFormMsg, UiMsg,
|
||||
UiAuthFormMsg, UiMsg, should_resolve_ssh_host_params,
|
||||
};
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::utils::ssh::resolve_ssh_host_params;
|
||||
|
||||
impl AuthActivity {
|
||||
pub(super) fn update(&mut self, msg: Option<Msg>) -> Option<Msg> {
|
||||
@@ -24,19 +26,26 @@ impl AuthActivity {
|
||||
fn update_form(&mut self, msg: FormMsg) -> Option<Msg> {
|
||||
match msg {
|
||||
FormMsg::Connect => {
|
||||
let Ok(remote_params) = self.collect_remote_host_params() else {
|
||||
// mount error
|
||||
self.mount_error("Invalid remote params parameters");
|
||||
return None;
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, false);
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false);
|
||||
let remote_params = match self.collect_remote_host_params() {
|
||||
Ok(remote_params) => remote_params,
|
||||
Err(err) => {
|
||||
// mount error
|
||||
self.mount_error(format!("Invalid remote host parameters: {err}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
|
||||
let Ok(host_bridge_params) = self.collect_host_bridge_params() else {
|
||||
// mount error
|
||||
self.mount_error("Invalid host bridge params parameters");
|
||||
return None;
|
||||
};
|
||||
|
||||
debug!("Remote params: {:?}", remote_params);
|
||||
|
||||
let host_bridge_params = match self.collect_host_bridge_params() {
|
||||
Ok(host_bridge_params) => host_bridge_params,
|
||||
Err(err) => {
|
||||
// mount error
|
||||
self.mount_error(format!("Invalid host bridge parameters: {err}"));
|
||||
return None;
|
||||
}
|
||||
};
|
||||
debug!("Host bridge params: {:?}", host_bridge_params);
|
||||
|
||||
self.save_recent();
|
||||
@@ -86,6 +95,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => &Id::Remote(AuthFormId::Password),
|
||||
InputMask::Smb => &Id::Remote(AuthFormId::Password),
|
||||
InputMask::AwsS3 => &Id::Remote(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => &Id::Remote(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => &Id::Remote(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => &Id::Remote(AuthFormId::Password),
|
||||
},
|
||||
@@ -94,6 +104,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => &Id::HostBridge(AuthFormId::Password),
|
||||
InputMask::Smb => &Id::HostBridge(AuthFormId::Password),
|
||||
InputMask::AwsS3 => &Id::HostBridge(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => &Id::HostBridge(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => &Id::HostBridge(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => &Id::HostBridge(AuthFormId::Password),
|
||||
},
|
||||
@@ -112,6 +123,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => &Id::Remote(AuthFormId::Password),
|
||||
InputMask::Smb => &Id::Remote(AuthFormId::Password),
|
||||
InputMask::AwsS3 => &Id::Remote(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => &Id::Remote(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => &Id::Remote(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => &Id::Remote(AuthFormId::Password),
|
||||
},
|
||||
@@ -120,6 +132,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => &Id::HostBridge(AuthFormId::Password),
|
||||
InputMask::Smb => &Id::HostBridge(AuthFormId::Password),
|
||||
InputMask::AwsS3 => &Id::HostBridge(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => &Id::HostBridge(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => &Id::HostBridge(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => &Id::HostBridge(AuthFormId::Password),
|
||||
},
|
||||
@@ -133,24 +146,36 @@ impl AuthActivity {
|
||||
self.host_bridge_protocol = protocol;
|
||||
// Update port
|
||||
let port: u16 = self.get_input_port(FormTab::HostBridge);
|
||||
if let HostBridgeProtocol::Remote(remote_protocol) = protocol
|
||||
&& Self::is_port_standard(port)
|
||||
{
|
||||
self.mount_port(
|
||||
FormTab::HostBridge,
|
||||
Self::get_default_port_for_protocol(remote_protocol),
|
||||
);
|
||||
if let HostBridgeProtocol::Remote(remote_protocol) = protocol {
|
||||
match remote_protocol {
|
||||
FileTransferProtocol::Scp | FileTransferProtocol::Sftp => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, true);
|
||||
}
|
||||
_ if Self::is_port_standard(port) => {
|
||||
self.mount_port(
|
||||
FormTab::HostBridge,
|
||||
Self::get_default_port_for_protocol(remote_protocol),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
FormMsg::RemoteProtocolChanged(protocol) => {
|
||||
self.remote_protocol = protocol;
|
||||
// Update port
|
||||
let port: u16 = self.get_input_port(FormTab::Remote);
|
||||
if Self::is_port_standard(port) {
|
||||
self.mount_port(
|
||||
FormTab::Remote,
|
||||
Self::get_default_port_for_protocol(protocol),
|
||||
);
|
||||
match protocol {
|
||||
FileTransferProtocol::Scp | FileTransferProtocol::Sftp => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, true);
|
||||
}
|
||||
_ if Self::is_port_standard(port) => {
|
||||
self.mount_port(
|
||||
FormTab::Remote,
|
||||
Self::get_default_port_for_protocol(protocol),
|
||||
);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
FormMsg::Quit => {
|
||||
@@ -244,6 +269,7 @@ impl AuthActivity {
|
||||
fn update_host_bridge_ui(&mut self, msg: UiAuthFormMsg) {
|
||||
match msg {
|
||||
UiAuthFormMsg::AddressBlurDown => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false);
|
||||
let id = if cfg!(windows) && self.host_bridge_input_mask() == InputMask::Smb {
|
||||
Id::HostBridge(AuthFormId::SmbShare)
|
||||
} else {
|
||||
@@ -252,9 +278,11 @@ impl AuthActivity {
|
||||
self.activate_component(id);
|
||||
}
|
||||
UiAuthFormMsg::AddressBlurUp => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false);
|
||||
self.activate_component(Id::HostBridge(AuthFormId::Protocol));
|
||||
}
|
||||
UiAuthFormMsg::ChangeFormTab => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false);
|
||||
self.last_form_tab = FormTab::Remote;
|
||||
self.activate_component(Id::Remote(AuthFormId::Protocol));
|
||||
}
|
||||
@@ -269,6 +297,7 @@ impl AuthActivity {
|
||||
self.activate_component(id);
|
||||
}
|
||||
UiAuthFormMsg::ParamsFormBlur => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::HostBridge, false);
|
||||
self.activate_component(Id::BookmarksList);
|
||||
}
|
||||
UiAuthFormMsg::PasswordBlurDown => {
|
||||
@@ -280,6 +309,7 @@ impl AuthActivity {
|
||||
#[cfg(win)]
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
InputMask::AwsS3 => unreachable!("this shouldn't happen (password on s3)"),
|
||||
InputMask::Gcs => unreachable!("this shouldn't happen (password on gcs)"),
|
||||
InputMask::Kube => unreachable!("this shouldn't happen (password on kube)"),
|
||||
InputMask::WebDAV => Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
};
|
||||
@@ -294,6 +324,7 @@ impl AuthActivity {
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::SmbShare),
|
||||
InputMask::Localhost
|
||||
| InputMask::AwsS3
|
||||
| InputMask::Gcs
|
||||
| InputMask::Kube
|
||||
| InputMask::WebDAV => {
|
||||
unreachable!("this shouldn't happen (port on s3/kube/webdav)")
|
||||
@@ -310,6 +341,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => Id::HostBridge(AuthFormId::Address),
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::Address),
|
||||
InputMask::AwsS3 => Id::HostBridge(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => Id::HostBridge(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => Id::HostBridge(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => Id::HostBridge(AuthFormId::WebDAVUri),
|
||||
};
|
||||
@@ -326,11 +358,12 @@ impl AuthActivity {
|
||||
InputMask::Localhost => unreachable!(),
|
||||
InputMask::Generic => Id::HostBridge(AuthFormId::Password),
|
||||
#[cfg(posix)]
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::SmbWorkgroup),
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::SmbDialect),
|
||||
#[cfg(win)]
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::Password),
|
||||
InputMask::Kube => Id::HostBridge(AuthFormId::KubeClientKey),
|
||||
InputMask::AwsS3 => Id::HostBridge(AuthFormId::S3NewPathStyle),
|
||||
InputMask::Gcs => Id::HostBridge(AuthFormId::GcsServiceAccountKey),
|
||||
InputMask::WebDAV => Id::HostBridge(AuthFormId::Password),
|
||||
};
|
||||
self.activate_component(id);
|
||||
@@ -389,6 +422,24 @@ impl AuthActivity {
|
||||
UiAuthFormMsg::S3NewPathStyleBlurUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::S3SessionToken))
|
||||
}
|
||||
UiAuthFormMsg::GcsBucketBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::GcsEndpoint))
|
||||
}
|
||||
UiAuthFormMsg::GcsBucketBlurUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::Protocol))
|
||||
}
|
||||
UiAuthFormMsg::GcsEndpointBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::GcsServiceAccountKey))
|
||||
}
|
||||
UiAuthFormMsg::GcsEndpointBlurUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::GcsBucket))
|
||||
}
|
||||
UiAuthFormMsg::GcsServiceAccountKeyBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory))
|
||||
}
|
||||
UiAuthFormMsg::GcsServiceAccountKeyBlurUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::GcsEndpoint))
|
||||
}
|
||||
UiAuthFormMsg::KubeClientCertBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::KubeClientKey))
|
||||
}
|
||||
@@ -432,12 +483,20 @@ impl AuthActivity {
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbWorkgroupDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory))
|
||||
self.activate_component(Id::HostBridge(AuthFormId::SmbDialect))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbWorkgroupUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::Password))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbDialectBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::RemoteDirectory))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbDialectBlurUp => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::SmbWorkgroup))
|
||||
}
|
||||
UiAuthFormMsg::UsernameBlurDown => {
|
||||
self.activate_component(Id::HostBridge(AuthFormId::Password))
|
||||
}
|
||||
@@ -448,6 +507,7 @@ impl AuthActivity {
|
||||
InputMask::Smb => Id::HostBridge(AuthFormId::SmbShare),
|
||||
InputMask::Kube => unreachable!("this shouldn't happen (username on kube)"),
|
||||
InputMask::AwsS3 => unreachable!("this shouldn't happen (username on s3)"),
|
||||
InputMask::Gcs => unreachable!("this shouldn't happen (username on gcs)"),
|
||||
InputMask::WebDAV => Id::HostBridge(AuthFormId::WebDAVUri),
|
||||
};
|
||||
self.activate_component(id);
|
||||
@@ -464,6 +524,7 @@ impl AuthActivity {
|
||||
fn update_remote_ui(&mut self, msg: UiAuthFormMsg) {
|
||||
match msg {
|
||||
UiAuthFormMsg::AddressBlurDown => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, false);
|
||||
let id = if cfg!(windows) && self.remote_input_mask() == InputMask::Smb {
|
||||
Id::Remote(AuthFormId::SmbShare)
|
||||
} else {
|
||||
@@ -472,9 +533,11 @@ impl AuthActivity {
|
||||
self.activate_component(id);
|
||||
}
|
||||
UiAuthFormMsg::AddressBlurUp => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, false);
|
||||
self.activate_component(Id::Remote(AuthFormId::Protocol));
|
||||
}
|
||||
UiAuthFormMsg::ChangeFormTab => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, false);
|
||||
self.last_form_tab = FormTab::HostBridge;
|
||||
self.activate_component(Id::HostBridge(AuthFormId::Protocol));
|
||||
}
|
||||
@@ -485,6 +548,7 @@ impl AuthActivity {
|
||||
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory));
|
||||
}
|
||||
UiAuthFormMsg::ParamsFormBlur => {
|
||||
self.resolve_ssh_host_params_if_needed(FormTab::Remote, false);
|
||||
self.activate_component(Id::BookmarksList);
|
||||
}
|
||||
UiAuthFormMsg::PasswordBlurDown => {
|
||||
@@ -496,6 +560,7 @@ impl AuthActivity {
|
||||
#[cfg(win)]
|
||||
InputMask::Smb => Id::Remote(AuthFormId::RemoteDirectory),
|
||||
InputMask::AwsS3 => unreachable!("this shouldn't happen (password on s3)"),
|
||||
InputMask::Gcs => unreachable!("this shouldn't happen (password on gcs)"),
|
||||
InputMask::Kube => unreachable!("this shouldn't happen (password on kube)"),
|
||||
InputMask::WebDAV => Id::Remote(AuthFormId::RemoteDirectory),
|
||||
};
|
||||
@@ -510,6 +575,7 @@ impl AuthActivity {
|
||||
InputMask::Smb => Id::Remote(AuthFormId::SmbShare),
|
||||
InputMask::Localhost
|
||||
| InputMask::AwsS3
|
||||
| InputMask::Gcs
|
||||
| InputMask::Kube
|
||||
| InputMask::WebDAV => {
|
||||
unreachable!("this shouldn't happen (port on s3/kube/webdav)")
|
||||
@@ -526,6 +592,7 @@ impl AuthActivity {
|
||||
InputMask::Generic => Id::Remote(AuthFormId::Address),
|
||||
InputMask::Smb => Id::Remote(AuthFormId::Address),
|
||||
InputMask::AwsS3 => Id::Remote(AuthFormId::S3Bucket),
|
||||
InputMask::Gcs => Id::Remote(AuthFormId::GcsBucket),
|
||||
InputMask::Kube => Id::Remote(AuthFormId::KubeNamespace),
|
||||
InputMask::WebDAV => Id::Remote(AuthFormId::WebDAVUri),
|
||||
};
|
||||
@@ -542,11 +609,12 @@ impl AuthActivity {
|
||||
InputMask::Localhost => unreachable!(),
|
||||
InputMask::Generic => Id::Remote(AuthFormId::Password),
|
||||
#[cfg(posix)]
|
||||
InputMask::Smb => Id::Remote(AuthFormId::SmbWorkgroup),
|
||||
InputMask::Smb => Id::Remote(AuthFormId::SmbDialect),
|
||||
#[cfg(win)]
|
||||
InputMask::Smb => Id::Remote(AuthFormId::Password),
|
||||
InputMask::Kube => Id::Remote(AuthFormId::KubeClientKey),
|
||||
InputMask::AwsS3 => Id::Remote(AuthFormId::S3NewPathStyle),
|
||||
InputMask::Gcs => Id::Remote(AuthFormId::GcsServiceAccountKey),
|
||||
InputMask::WebDAV => Id::Remote(AuthFormId::Password),
|
||||
};
|
||||
self.activate_component(id);
|
||||
@@ -605,6 +673,24 @@ impl AuthActivity {
|
||||
UiAuthFormMsg::S3NewPathStyleBlurUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::S3SessionToken))
|
||||
}
|
||||
UiAuthFormMsg::GcsBucketBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::GcsEndpoint))
|
||||
}
|
||||
UiAuthFormMsg::GcsBucketBlurUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::Protocol))
|
||||
}
|
||||
UiAuthFormMsg::GcsEndpointBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::GcsServiceAccountKey))
|
||||
}
|
||||
UiAuthFormMsg::GcsEndpointBlurUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::GcsBucket))
|
||||
}
|
||||
UiAuthFormMsg::GcsServiceAccountKeyBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory))
|
||||
}
|
||||
UiAuthFormMsg::GcsServiceAccountKeyBlurUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::GcsEndpoint))
|
||||
}
|
||||
UiAuthFormMsg::KubeClientCertBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::KubeClientKey))
|
||||
}
|
||||
@@ -648,12 +734,20 @@ impl AuthActivity {
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbWorkgroupDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory))
|
||||
self.activate_component(Id::Remote(AuthFormId::SmbDialect))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbWorkgroupUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::Password))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbDialectBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::RemoteDirectory))
|
||||
}
|
||||
#[cfg(posix)]
|
||||
UiAuthFormMsg::SmbDialectBlurUp => {
|
||||
self.activate_component(Id::Remote(AuthFormId::SmbWorkgroup))
|
||||
}
|
||||
UiAuthFormMsg::UsernameBlurDown => {
|
||||
self.activate_component(Id::Remote(AuthFormId::Password))
|
||||
}
|
||||
@@ -664,6 +758,7 @@ impl AuthActivity {
|
||||
InputMask::Smb => Id::Remote(AuthFormId::SmbShare),
|
||||
InputMask::Kube => unreachable!("this shouldn't happen (username on kube)"),
|
||||
InputMask::AwsS3 => unreachable!("this shouldn't happen (username on s3)"),
|
||||
InputMask::Gcs => unreachable!("this shouldn't happen (username on gcs)"),
|
||||
InputMask::WebDAV => Id::Remote(AuthFormId::WebDAVUri),
|
||||
};
|
||||
self.activate_component(id);
|
||||
@@ -677,6 +772,35 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_ssh_host_params_if_needed(&mut self, form_tab: FormTab, force: bool) {
|
||||
let protocol = match form_tab {
|
||||
FormTab::HostBridge => match self.host_bridge_protocol {
|
||||
HostBridgeProtocol::Localhost => return,
|
||||
HostBridgeProtocol::Remote(protocol) => protocol,
|
||||
},
|
||||
FormTab::Remote => self.remote_protocol,
|
||||
};
|
||||
let address = self.get_input_addr(form_tab);
|
||||
let should_resolve = should_resolve_ssh_host_params(
|
||||
protocol,
|
||||
self.last_mounted_address(form_tab),
|
||||
address.as_str(),
|
||||
force,
|
||||
);
|
||||
if !should_resolve {
|
||||
return;
|
||||
}
|
||||
|
||||
let params = resolve_ssh_host_params(self.context().ssh_config(), address.as_str());
|
||||
self.mount_port(form_tab, 22);
|
||||
self.mount_username(form_tab, "");
|
||||
self.mount_port(form_tab, params.port);
|
||||
if let Some(username) = params.username {
|
||||
self.mount_username(form_tab, username.as_str());
|
||||
}
|
||||
self.set_last_mounted_address(form_tab, address.as_str());
|
||||
}
|
||||
|
||||
fn activate_component(&mut self, id: Id) {
|
||||
if let Err(err) = self.app.active(&id) {
|
||||
error!("Failed to activate component: {err}");
|
||||
|
||||
+164
-56
@@ -3,7 +3,7 @@
|
||||
//! `auth_activity` is the module which implements the authentication activity
|
||||
|
||||
use tuirealm::props::Color;
|
||||
use tuirealm::ratatui::layout::{Constraint, Direction, Layout};
|
||||
use tuirealm::ratatui::layout::{Constraint, Direction, Layout, Rect};
|
||||
use tuirealm::ratatui::widgets::Clear;
|
||||
use tuirealm::terminal::TerminalAdapter;
|
||||
|
||||
@@ -11,6 +11,9 @@ use super::{
|
||||
AuthActivity, AuthFormId, Context, FileTransferProtocol, FormTab, HostBridgeProtocol, Id,
|
||||
InputMask, components,
|
||||
};
|
||||
use crate::filetransfer::params::DEFAULT_GCS_ENDPOINT;
|
||||
#[cfg(posix)]
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
use crate::utils::ui::{Popup, Size};
|
||||
|
||||
#[path = "view/mounting.rs"]
|
||||
@@ -52,6 +55,9 @@ impl AuthActivity {
|
||||
self.mount_port(FormTab::HostBridge, 22);
|
||||
self.mount_username(FormTab::HostBridge, "");
|
||||
self.mount_password(FormTab::HostBridge, "");
|
||||
self.mount_gcs_bucket(FormTab::HostBridge, "");
|
||||
self.mount_gcs_endpoint(FormTab::HostBridge, DEFAULT_GCS_ENDPOINT);
|
||||
self.mount_gcs_service_account_key(FormTab::HostBridge, "");
|
||||
self.mount_s3_bucket(FormTab::HostBridge, "");
|
||||
self.mount_s3_profile(FormTab::HostBridge, "");
|
||||
self.mount_s3_region(FormTab::HostBridge, "");
|
||||
@@ -69,9 +75,14 @@ impl AuthActivity {
|
||||
self.mount_smb_share(FormTab::HostBridge, "");
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_workgroup(FormTab::HostBridge, "");
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_dialect(FormTab::HostBridge, SmbDialect::default());
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_dialect_warning(FormTab::HostBridge);
|
||||
self.mount_webdav_uri(FormTab::HostBridge, "");
|
||||
|
||||
let remote_default_protocol = self.context().config().get_default_protocol();
|
||||
self.set_remote_protocol(remote_default_protocol);
|
||||
self.mount_remote_protocol(remote_default_protocol);
|
||||
self.mount_remote_directory(FormTab::Remote, "");
|
||||
self.mount_local_directory(FormTab::Remote, "");
|
||||
@@ -82,6 +93,9 @@ impl AuthActivity {
|
||||
);
|
||||
self.mount_username(FormTab::Remote, "");
|
||||
self.mount_password(FormTab::Remote, "");
|
||||
self.mount_gcs_bucket(FormTab::Remote, "");
|
||||
self.mount_gcs_endpoint(FormTab::Remote, DEFAULT_GCS_ENDPOINT);
|
||||
self.mount_gcs_service_account_key(FormTab::Remote, "");
|
||||
self.mount_s3_bucket(FormTab::Remote, "");
|
||||
self.mount_s3_profile(FormTab::Remote, "");
|
||||
self.mount_s3_region(FormTab::Remote, "");
|
||||
@@ -99,6 +113,10 @@ impl AuthActivity {
|
||||
self.mount_smb_share(FormTab::Remote, "");
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_workgroup(FormTab::Remote, "");
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_dialect(FormTab::Remote, SmbDialect::default());
|
||||
#[cfg(posix)]
|
||||
self.mount_smb_dialect_warning(FormTab::Remote);
|
||||
self.mount_webdav_uri(FormTab::Remote, "");
|
||||
|
||||
if let Some(version) = self
|
||||
@@ -239,8 +257,10 @@ impl AuthActivity {
|
||||
f: &mut tuirealm::ratatui::Frame<'_>,
|
||||
area: tuirealm::ratatui::layout::Rect,
|
||||
) {
|
||||
let input_mask_size = Self::input_mask_size(self.host_bridge_input_mask());
|
||||
let input_mask = self.host_bridge_input_mask();
|
||||
let protocol_and_mask_chunks = Layout::default()
|
||||
.constraints([Constraint::Length(3), Constraint::Length(12)].as_ref())
|
||||
.constraints([Constraint::Length(3), Constraint::Length(input_mask_size)].as_ref())
|
||||
.direction(Direction::Vertical)
|
||||
.split(area);
|
||||
|
||||
@@ -250,35 +270,25 @@ impl AuthActivity {
|
||||
protocol_and_mask_chunks[0],
|
||||
);
|
||||
|
||||
let input_mask = Layout::default()
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.direction(Direction::Vertical)
|
||||
.split(protocol_and_mask_chunks[1]);
|
||||
match self.host_bridge_input_mask() {
|
||||
InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_host_bridge_s3_view()),
|
||||
InputMask::Generic => {
|
||||
self.render_view_ids(f, input_mask, self.get_host_bridge_generic_params_view())
|
||||
}
|
||||
InputMask::Kube => {
|
||||
self.render_view_ids(f, input_mask, self.get_host_bridge_kube_view())
|
||||
}
|
||||
let view_ids = match input_mask {
|
||||
InputMask::AwsS3 => self.get_host_bridge_s3_view(),
|
||||
InputMask::Gcs => self.get_host_bridge_gcs_view(),
|
||||
InputMask::Generic => self.get_host_bridge_generic_params_view(),
|
||||
InputMask::Kube => self.get_host_bridge_kube_view(),
|
||||
InputMask::Localhost => {
|
||||
let view_ids = self.get_host_bridge_localhost_view();
|
||||
self.app.view(&view_ids[0], f, input_mask[0]);
|
||||
self.app.view(&view_ids[0], f, protocol_and_mask_chunks[1]);
|
||||
return;
|
||||
}
|
||||
InputMask::Smb => self.render_view_ids(f, input_mask, self.get_host_bridge_smb_view()),
|
||||
InputMask::WebDAV => {
|
||||
self.render_view_ids(f, input_mask, self.get_host_bridge_webdav_view())
|
||||
}
|
||||
}
|
||||
InputMask::Smb => self.get_host_bridge_smb_view(),
|
||||
InputMask::WebDAV => self.get_host_bridge_webdav_view(),
|
||||
};
|
||||
self.render_form_rows(
|
||||
f,
|
||||
protocol_and_mask_chunks[1],
|
||||
FormTab::HostBridge,
|
||||
view_ids,
|
||||
);
|
||||
}
|
||||
|
||||
fn render_remote_input_mask(
|
||||
@@ -286,8 +296,10 @@ impl AuthActivity {
|
||||
f: &mut tuirealm::ratatui::Frame<'_>,
|
||||
area: tuirealm::ratatui::layout::Rect,
|
||||
) {
|
||||
let input_mask_size = Self::input_mask_size(self.remote_input_mask());
|
||||
let input_mask = self.remote_input_mask();
|
||||
let protocol_and_mask_chunks = Layout::default()
|
||||
.constraints([Constraint::Length(3), Constraint::Length(12)].as_ref())
|
||||
.constraints([Constraint::Length(3), Constraint::Length(input_mask_size)].as_ref())
|
||||
.direction(Direction::Vertical)
|
||||
.split(area);
|
||||
|
||||
@@ -297,39 +309,135 @@ impl AuthActivity {
|
||||
protocol_and_mask_chunks[0],
|
||||
);
|
||||
|
||||
let input_mask = Layout::default()
|
||||
.constraints(
|
||||
[
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
Constraint::Length(3),
|
||||
]
|
||||
.as_ref(),
|
||||
)
|
||||
.direction(Direction::Vertical)
|
||||
.split(protocol_and_mask_chunks[1]);
|
||||
match self.remote_input_mask() {
|
||||
InputMask::AwsS3 => self.render_view_ids(f, input_mask, self.get_remote_s3_view()),
|
||||
InputMask::Generic => {
|
||||
self.render_view_ids(f, input_mask, self.get_remote_generic_params_view())
|
||||
}
|
||||
InputMask::Kube => self.render_view_ids(f, input_mask, self.get_remote_kube_view()),
|
||||
let view_ids = match input_mask {
|
||||
InputMask::AwsS3 => self.get_remote_s3_view(),
|
||||
InputMask::Gcs => self.get_remote_gcs_view(),
|
||||
InputMask::Generic => self.get_remote_generic_params_view(),
|
||||
InputMask::Kube => self.get_remote_kube_view(),
|
||||
InputMask::Localhost => unreachable!(),
|
||||
InputMask::Smb => self.render_view_ids(f, input_mask, self.get_remote_smb_view()),
|
||||
InputMask::WebDAV => self.render_view_ids(f, input_mask, self.get_remote_webdav_view()),
|
||||
}
|
||||
InputMask::Smb => self.get_remote_smb_view(),
|
||||
InputMask::WebDAV => self.get_remote_webdav_view(),
|
||||
};
|
||||
self.render_form_rows(f, protocol_and_mask_chunks[1], FormTab::Remote, view_ids);
|
||||
}
|
||||
|
||||
fn render_view_ids(
|
||||
/// Splits `area` into four 3-line form rows. When `warning_row` is
|
||||
/// `Some(index)`, a 1-line row is inserted directly above row `index` and
|
||||
/// returned as the second tuple element.
|
||||
fn split_input_mask(area: Rect, warning_row: Option<usize>) -> ([Rect; 4], Option<Rect>) {
|
||||
let mut constraints = Vec::with_capacity(6);
|
||||
for row in 0..4 {
|
||||
if warning_row == Some(row) {
|
||||
constraints.push(Constraint::Length(1));
|
||||
}
|
||||
constraints.push(Constraint::Length(3));
|
||||
}
|
||||
constraints.push(Constraint::Min(0));
|
||||
let chunks = Layout::default()
|
||||
.constraints(constraints)
|
||||
.direction(Direction::Vertical)
|
||||
.split(area);
|
||||
|
||||
let mut rows = [Rect::default(); 4];
|
||||
let mut warning = None;
|
||||
let mut chunk = 0;
|
||||
for (row, slot) in rows.iter_mut().enumerate() {
|
||||
if warning_row == Some(row) {
|
||||
warning = Some(chunks[chunk]);
|
||||
chunk += 1;
|
||||
}
|
||||
*slot = chunks[chunk];
|
||||
chunk += 1;
|
||||
}
|
||||
(rows, warning)
|
||||
}
|
||||
|
||||
/// Returns the visible row index of the SMB dialect radio when the form
|
||||
/// shows SMB and SMB1 is selected; `None` otherwise.
|
||||
#[cfg(posix)]
|
||||
fn smb_dialect_warning_row(&self, form_tab: FormTab, view_ids: &[Id; 4]) -> Option<usize> {
|
||||
let input_mask = match form_tab {
|
||||
FormTab::HostBridge => self.host_bridge_input_mask(),
|
||||
FormTab::Remote => self.remote_input_mask(),
|
||||
};
|
||||
if input_mask != InputMask::Smb || self.get_input_smb_dialect(form_tab) != SmbDialect::Smb1
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let dialect_id = Self::form_tab_id(form_tab, AuthFormId::SmbDialect);
|
||||
view_ids.iter().position(|id| *id == dialect_id)
|
||||
}
|
||||
|
||||
#[cfg(win)]
|
||||
fn smb_dialect_warning_row(&self, _form_tab: FormTab, _view_ids: &[Id; 4]) -> Option<usize> {
|
||||
None
|
||||
}
|
||||
|
||||
fn render_form_rows(
|
||||
&mut self,
|
||||
f: &mut tuirealm::ratatui::Frame<'_>,
|
||||
input_mask: std::rc::Rc<[tuirealm::ratatui::layout::Rect]>,
|
||||
area: Rect,
|
||||
form_tab: FormTab,
|
||||
view_ids: [Id; 4],
|
||||
) {
|
||||
self.app.view(&view_ids[0], f, input_mask[0]);
|
||||
self.app.view(&view_ids[1], f, input_mask[1]);
|
||||
self.app.view(&view_ids[2], f, input_mask[2]);
|
||||
self.app.view(&view_ids[3], f, input_mask[3]);
|
||||
let warning_row = self.smb_dialect_warning_row(form_tab, &view_ids);
|
||||
let (rows, warning) = Self::split_input_mask(area, warning_row);
|
||||
#[cfg(posix)]
|
||||
if let Some(rect) = warning {
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialectWarning);
|
||||
self.app.view(&id, f, rect);
|
||||
}
|
||||
#[cfg(win)]
|
||||
let _ = warning;
|
||||
for (id, rect) in view_ids.iter().zip(rows) {
|
||||
self.app.view(id, f, rect);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use pretty_assertions::assert_eq;
|
||||
use tuirealm::ratatui::layout::Rect;
|
||||
|
||||
use super::AuthActivity;
|
||||
|
||||
#[test]
|
||||
fn should_split_input_mask_without_warning() {
|
||||
let area = Rect::new(0, 0, 40, 13);
|
||||
|
||||
let (rows, warning) = AuthActivity::split_input_mask(area, None);
|
||||
|
||||
assert_eq!(warning, None);
|
||||
assert_eq!(rows[0], Rect::new(0, 0, 40, 3));
|
||||
assert_eq!(rows[1], Rect::new(0, 3, 40, 3));
|
||||
assert_eq!(rows[2], Rect::new(0, 6, 40, 3));
|
||||
assert_eq!(rows[3], Rect::new(0, 9, 40, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_split_input_mask_with_middle_warning() {
|
||||
let area = Rect::new(0, 0, 40, 13);
|
||||
|
||||
let (rows, warning) = AuthActivity::split_input_mask(area, Some(2));
|
||||
|
||||
assert_eq!(warning, Some(Rect::new(0, 6, 40, 1)));
|
||||
assert_eq!(rows[0], Rect::new(0, 0, 40, 3));
|
||||
assert_eq!(rows[1], Rect::new(0, 3, 40, 3));
|
||||
assert_eq!(rows[2], Rect::new(0, 7, 40, 3));
|
||||
assert_eq!(rows[3], Rect::new(0, 10, 40, 3));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_split_input_mask_with_first_row_warning() {
|
||||
let area = Rect::new(0, 0, 40, 13);
|
||||
|
||||
let (rows, warning) = AuthActivity::split_input_mask(area, Some(0));
|
||||
|
||||
assert_eq!(warning, Some(Rect::new(0, 0, 40, 1)));
|
||||
assert_eq!(rows[0], Rect::new(0, 1, 40, 3));
|
||||
assert_eq!(rows[1], Rect::new(0, 4, 40, 3));
|
||||
assert_eq!(rows[2], Rect::new(0, 7, 40, 3));
|
||||
assert_eq!(rows[3], Rect::new(0, 10, 40, 3));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
use super::*;
|
||||
#[cfg(posix)]
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
use crate::ui::activities::auth::STORE_KEY_RELEASE_NOTES;
|
||||
|
||||
impl AuthActivity {
|
||||
@@ -320,6 +322,7 @@ impl AuthActivity {
|
||||
form_tab: FormTab,
|
||||
address: &str,
|
||||
) {
|
||||
self.set_last_mounted_address(form_tab, address);
|
||||
let addr_color = self.theme().auth_address;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::Address);
|
||||
if let Err(err) = self.app.remount(
|
||||
@@ -383,6 +386,56 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn mount_gcs_bucket(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
bucket: &str,
|
||||
) {
|
||||
let color = self.theme().auth_address;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::GcsBucket);
|
||||
if let Err(err) = self.app.remount(
|
||||
id,
|
||||
Box::new(components::InputGcsBucket::new(bucket, form_tab, color)),
|
||||
vec![],
|
||||
) {
|
||||
error!("Failed to remount component: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn mount_gcs_endpoint(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
endpoint: &str,
|
||||
) {
|
||||
let color = self.theme().auth_username;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::GcsEndpoint);
|
||||
if let Err(err) = self.app.remount(
|
||||
id,
|
||||
Box::new(components::InputGcsEndpoint::new(endpoint, form_tab, color)),
|
||||
vec![],
|
||||
) {
|
||||
error!("Failed to remount component: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn mount_gcs_service_account_key(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
path: &str,
|
||||
) {
|
||||
let color = self.theme().auth_password;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::GcsServiceAccountKey);
|
||||
if let Err(err) = self.app.remount(
|
||||
id,
|
||||
Box::new(components::InputGcsServiceAccountKey::new(
|
||||
path, form_tab, color,
|
||||
)),
|
||||
vec![],
|
||||
) {
|
||||
error!("Failed to remount component: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn mount_s3_bucket(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
@@ -654,6 +707,36 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
pub(in crate::ui::activities::auth) fn mount_smb_dialect(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
dialect: SmbDialect,
|
||||
) {
|
||||
let color = self.theme().auth_protocol;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialect);
|
||||
if let Err(err) = self.app.remount(
|
||||
id,
|
||||
Box::new(components::RadioSmbDialect::new(dialect, form_tab, color)),
|
||||
vec![],
|
||||
) {
|
||||
error!("Failed to remount component: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
pub(in crate::ui::activities::auth) fn mount_smb_dialect_warning(&mut self, form_tab: FormTab) {
|
||||
let color = self.theme().misc_warn_dialog;
|
||||
let id = Self::form_tab_id(form_tab, AuthFormId::SmbDialectWarning);
|
||||
if let Err(err) = self.app.remount(
|
||||
id,
|
||||
Box::new(components::SmbDialectWarning::new(color)),
|
||||
vec![],
|
||||
) {
|
||||
error!("Failed to remount component: {err}");
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn mount_webdav_uri(
|
||||
&mut self,
|
||||
form_tab: FormTab,
|
||||
|
||||
@@ -5,10 +5,14 @@ use tuirealm::state::{State, StateValue};
|
||||
|
||||
use super::*;
|
||||
use crate::filetransfer::FileTransferParams;
|
||||
#[cfg(posix)]
|
||||
use crate::filetransfer::params::SmbDialect;
|
||||
use crate::filetransfer::params::{
|
||||
AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, SmbParams,
|
||||
WebDAVProtocolParams,
|
||||
AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams,
|
||||
ProtocolParams, SmbParams, WebDAVProtocolParams,
|
||||
};
|
||||
#[cfg(posix)]
|
||||
use crate::ui::activities::auth::components::RadioSmbDialect;
|
||||
|
||||
impl AuthActivity {
|
||||
pub(in crate::ui::activities::auth) fn get_generic_params_input(
|
||||
@@ -48,6 +52,15 @@ impl AuthActivity {
|
||||
.new_path_style(new_path_style)
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_gcs_params_input(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> GoogleCloudStorageParams {
|
||||
GoogleCloudStorageParams::new(self.get_input_gcs_bucket(form_tab))
|
||||
.endpoint(self.get_input_gcs_endpoint(form_tab))
|
||||
.service_account_key(self.get_input_gcs_service_account_key(form_tab))
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_kube_params_input(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
@@ -73,6 +86,7 @@ impl AuthActivity {
|
||||
) -> SmbParams {
|
||||
let share = self.get_input_smb_share(form_tab);
|
||||
let workgroup = self.get_input_smb_workgroup(form_tab);
|
||||
let dialect = self.get_input_smb_dialect(form_tab);
|
||||
let address = self.get_input_addr(form_tab);
|
||||
let port = self.get_input_port(form_tab);
|
||||
let username = self.get_input_username(form_tab);
|
||||
@@ -83,6 +97,7 @@ impl AuthActivity {
|
||||
.username(username)
|
||||
.password(password)
|
||||
.workgroup(workgroup)
|
||||
.dialect(dialect)
|
||||
}
|
||||
|
||||
#[cfg(win)]
|
||||
@@ -213,6 +228,45 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_input_gcs_bucket(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> String {
|
||||
match self
|
||||
.app
|
||||
.state(&Self::form_tab_id(form_tab, AuthFormId::GcsBucket))
|
||||
{
|
||||
Ok(State::Single(StateValue::String(value))) => value,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_input_gcs_endpoint(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> String {
|
||||
match self
|
||||
.app
|
||||
.state(&Self::form_tab_id(form_tab, AuthFormId::GcsEndpoint))
|
||||
{
|
||||
Ok(State::Single(StateValue::String(value))) => value,
|
||||
_ => String::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_input_gcs_service_account_key(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> Option<String> {
|
||||
match self.app.state(&Self::form_tab_id(
|
||||
form_tab,
|
||||
AuthFormId::GcsServiceAccountKey,
|
||||
)) {
|
||||
Ok(State::Single(StateValue::String(value))) if !value.is_empty() => Some(value),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_input_s3_region(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
@@ -404,6 +458,20 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(posix)]
|
||||
pub(in crate::ui::activities::auth) fn get_input_smb_dialect(
|
||||
&self,
|
||||
form_tab: FormTab,
|
||||
) -> SmbDialect {
|
||||
match self
|
||||
.app
|
||||
.state(&Self::form_tab_id(form_tab, AuthFormId::SmbDialect))
|
||||
{
|
||||
Ok(State::Single(StateValue::Usize(opt))) => RadioSmbDialect::opt_to_dialect(opt),
|
||||
_ => SmbDialect::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_new_bookmark(&self) -> (String, bool) {
|
||||
let name = match self.app.state(&Id::BookmarkName) {
|
||||
Ok(State::Single(StateValue::String(name))) => name,
|
||||
@@ -425,9 +493,12 @@ impl AuthActivity {
|
||||
+ 3
|
||||
}
|
||||
|
||||
fn input_mask_size(input_mask: InputMask) -> u16 {
|
||||
pub(in crate::ui::activities::auth) fn input_mask_size(input_mask: InputMask) -> u16 {
|
||||
match input_mask {
|
||||
// One extra line for the SMB1 warning above the dialect radio.
|
||||
InputMask::Smb if cfg!(posix) => 13,
|
||||
InputMask::AwsS3
|
||||
| InputMask::Gcs
|
||||
| InputMask::Generic
|
||||
| InputMask::Kube
|
||||
| InputMask::Smb
|
||||
@@ -446,6 +517,11 @@ impl AuthActivity {
|
||||
|
||||
pub(in crate::ui::activities::auth) fn fmt_recent(b: FileTransferParams) -> String {
|
||||
let protocol = b.protocol.to_string().to_lowercase();
|
||||
let remote_path = b
|
||||
.remote_path
|
||||
.as_ref()
|
||||
.map(|path| format!(" {}", path.display()))
|
||||
.unwrap_or_default();
|
||||
match b.params {
|
||||
ProtocolParams::AwsS3(s3) => {
|
||||
let profile = match s3.profile {
|
||||
@@ -471,6 +547,15 @@ impl AuthActivity {
|
||||
protocol, username, params.address, params.port
|
||||
)
|
||||
}
|
||||
ProtocolParams::GoogleCloudStorage(params) => format!(
|
||||
"{protocol}://{} ({}){remote_path}",
|
||||
params.bucket_name,
|
||||
if params.endpoint.is_empty() {
|
||||
crate::filetransfer::params::DEFAULT_GCS_ENDPOINT
|
||||
} else {
|
||||
params.endpoint.as_str()
|
||||
},
|
||||
),
|
||||
ProtocolParams::Kube(params) => {
|
||||
format!(
|
||||
"{}://{}{}",
|
||||
|
||||
@@ -159,6 +159,40 @@ impl AuthActivity {
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_host_bridge_gcs_view(&self) -> [Id; 4] {
|
||||
match self.app.focus() {
|
||||
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
|
||||
Id::HostBridge(AuthFormId::GcsEndpoint),
|
||||
Id::HostBridge(AuthFormId::GcsServiceAccountKey),
|
||||
Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
Id::HostBridge(AuthFormId::LocalDirectory),
|
||||
],
|
||||
_ => [
|
||||
Id::HostBridge(AuthFormId::GcsBucket),
|
||||
Id::HostBridge(AuthFormId::GcsEndpoint),
|
||||
Id::HostBridge(AuthFormId::GcsServiceAccountKey),
|
||||
Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_remote_gcs_view(&self) -> [Id; 4] {
|
||||
match self.app.focus() {
|
||||
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
|
||||
Id::Remote(AuthFormId::GcsEndpoint),
|
||||
Id::Remote(AuthFormId::GcsServiceAccountKey),
|
||||
Id::Remote(AuthFormId::RemoteDirectory),
|
||||
Id::Remote(AuthFormId::LocalDirectory),
|
||||
],
|
||||
_ => [
|
||||
Id::Remote(AuthFormId::GcsBucket),
|
||||
Id::Remote(AuthFormId::GcsEndpoint),
|
||||
Id::Remote(AuthFormId::GcsServiceAccountKey),
|
||||
Id::Remote(AuthFormId::RemoteDirectory),
|
||||
],
|
||||
}
|
||||
}
|
||||
|
||||
pub(in crate::ui::activities::auth) fn get_host_bridge_kube_view(&self) -> [Id; 4] {
|
||||
match self.app.focus() {
|
||||
Some(&Id::HostBridge(AuthFormId::KubeClientCert)) => [
|
||||
@@ -255,15 +289,21 @@ impl AuthActivity {
|
||||
Id::HostBridge(AuthFormId::Password),
|
||||
Id::HostBridge(AuthFormId::SmbWorkgroup),
|
||||
],
|
||||
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
|
||||
Some(&Id::HostBridge(AuthFormId::SmbDialect)) => [
|
||||
Id::HostBridge(AuthFormId::Username),
|
||||
Id::HostBridge(AuthFormId::Password),
|
||||
Id::HostBridge(AuthFormId::SmbWorkgroup),
|
||||
Id::HostBridge(AuthFormId::SmbDialect),
|
||||
],
|
||||
Some(&Id::HostBridge(AuthFormId::RemoteDirectory)) => [
|
||||
Id::HostBridge(AuthFormId::Password),
|
||||
Id::HostBridge(AuthFormId::SmbWorkgroup),
|
||||
Id::HostBridge(AuthFormId::SmbDialect),
|
||||
Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
],
|
||||
Some(&Id::HostBridge(AuthFormId::LocalDirectory)) => [
|
||||
Id::HostBridge(AuthFormId::Password),
|
||||
Id::HostBridge(AuthFormId::SmbWorkgroup),
|
||||
Id::HostBridge(AuthFormId::SmbDialect),
|
||||
Id::HostBridge(AuthFormId::RemoteDirectory),
|
||||
Id::HostBridge(AuthFormId::LocalDirectory),
|
||||
],
|
||||
@@ -302,15 +342,21 @@ impl AuthActivity {
|
||||
Id::Remote(AuthFormId::Password),
|
||||
Id::Remote(AuthFormId::SmbWorkgroup),
|
||||
],
|
||||
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
|
||||
Some(&Id::Remote(AuthFormId::SmbDialect)) => [
|
||||
Id::Remote(AuthFormId::Username),
|
||||
Id::Remote(AuthFormId::Password),
|
||||
Id::Remote(AuthFormId::SmbWorkgroup),
|
||||
Id::Remote(AuthFormId::SmbDialect),
|
||||
],
|
||||
Some(&Id::Remote(AuthFormId::RemoteDirectory)) => [
|
||||
Id::Remote(AuthFormId::Password),
|
||||
Id::Remote(AuthFormId::SmbWorkgroup),
|
||||
Id::Remote(AuthFormId::SmbDialect),
|
||||
Id::Remote(AuthFormId::RemoteDirectory),
|
||||
],
|
||||
Some(&Id::Remote(AuthFormId::LocalDirectory)) => [
|
||||
Id::Remote(AuthFormId::Password),
|
||||
Id::Remote(AuthFormId::SmbWorkgroup),
|
||||
Id::Remote(AuthFormId::SmbDialect),
|
||||
Id::Remote(AuthFormId::RemoteDirectory),
|
||||
Id::Remote(AuthFormId::LocalDirectory),
|
||||
],
|
||||
|
||||
@@ -28,8 +28,6 @@ impl Pane {
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use std::path::PathBuf;
|
||||
|
||||
use super::*;
|
||||
use crate::explorer::builder::FileExplorerBuilder;
|
||||
use crate::host::Localhost;
|
||||
@@ -52,6 +50,6 @@ mod tests {
|
||||
fn test_pane_pwd() {
|
||||
let mut pane = make_pane();
|
||||
let pwd = pane.fs.pwd().unwrap();
|
||||
assert_eq!(pwd, PathBuf::from(std::env::temp_dir()));
|
||||
assert_eq!(pwd, std::env::temp_dir());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -64,6 +64,7 @@ impl FileTransferActivity {
|
||||
match params {
|
||||
ProtocolParams::Generic(params) => params.address.clone(),
|
||||
ProtocolParams::AwsS3(params) => params.bucket_name.clone(),
|
||||
ProtocolParams::GoogleCloudStorage(params) => params.bucket_name.clone(),
|
||||
ProtocolParams::Kube(params) => {
|
||||
params.namespace.clone().unwrap_or("default".to_string())
|
||||
}
|
||||
@@ -93,6 +94,11 @@ impl FileTransferActivity {
|
||||
);
|
||||
format!("Connecting to {}…", params.bucket_name)
|
||||
}
|
||||
ProtocolParams::GoogleCloudStorage(params) => format!(
|
||||
"Connecting to GCS bucket '{bucket}' at {endpoint}…",
|
||||
bucket = params.bucket_name,
|
||||
endpoint = params.endpoint,
|
||||
),
|
||||
ProtocolParams::Kube(params) => {
|
||||
let namespace = params.namespace.as_deref().unwrap_or("default");
|
||||
info!("Client is not connected to remote; connecting to namespace {namespace}",);
|
||||
|
||||
@@ -32,6 +32,10 @@ enum TransferErrorReason {
|
||||
RemoteHostError(HostError),
|
||||
}
|
||||
|
||||
fn handle_remote_finalize_result(result: Result<(), HostError>) -> Result<(), TransferErrorReason> {
|
||||
result.map_err(TransferErrorReason::RemoteHostError)
|
||||
}
|
||||
|
||||
/// Represents the entity to send or receive during a transfer.
|
||||
/// - File: describes an individual `File` to send
|
||||
/// - Any: Can be any kind of `File`, but just one
|
||||
@@ -211,7 +215,8 @@ impl FileTransferActivity {
|
||||
/// Shared scan walk. `remote_side` selects which pane lists directories
|
||||
/// (remote for downloads, local for uploads).
|
||||
///
|
||||
/// The walk is abortable via [`crate::ui::activities::filetransfer::lib::TransferStates::aborted`]
|
||||
/// The walk is abortable via
|
||||
/// [`TransferStates::aborted`](crate::ui::activities::filetransfer::lib::transfer::TransferStates::aborted)
|
||||
/// and periodically redraws a "Scanning…" popup to keep the UI responsive.
|
||||
fn scan_worklist(
|
||||
&mut self,
|
||||
@@ -524,12 +529,7 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
// Finalize stream
|
||||
if let Err(err) = self.browser.remote_pane_mut().fs.finalize_write(writer) {
|
||||
self.log(
|
||||
LogLevel::Warn,
|
||||
format!("Could not finalize remote stream: \"{err}\""),
|
||||
);
|
||||
}
|
||||
handle_remote_finalize_result(self.browser.remote_pane_mut().fs.finalize_write(writer))?;
|
||||
// if upload was abrupted, return error
|
||||
if self.transfer.aborted() {
|
||||
return Err(TransferErrorReason::Abrupted);
|
||||
@@ -921,6 +921,24 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod finalize_test {
|
||||
use super::*;
|
||||
use crate::host::HostErrorType;
|
||||
|
||||
#[test]
|
||||
fn should_propagate_remote_finalize_error() {
|
||||
let error = HostError::from(HostErrorType::CouldNotCreateFile);
|
||||
|
||||
let result = handle_remote_finalize_result(Err(error));
|
||||
|
||||
assert!(matches!(
|
||||
result,
|
||||
Err(TransferErrorReason::RemoteHostError(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod worklist_test {
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -32,8 +32,9 @@ const RADIO_PROTOCOL_FTP: usize = 2;
|
||||
const RADIO_PROTOCOL_FTPS: usize = 3;
|
||||
const RADIO_PROTOCOL_KUBE: usize = 4;
|
||||
const RADIO_PROTOCOL_S3: usize = 5;
|
||||
const RADIO_PROTOCOL_SMB: usize = 6;
|
||||
const RADIO_PROTOCOL_WEBDAV: usize = 7;
|
||||
const RADIO_PROTOCOL_GCS: usize = 6;
|
||||
const RADIO_PROTOCOL_SMB: usize = 7;
|
||||
const RADIO_PROTOCOL_WEBDAV: usize = 8;
|
||||
|
||||
// -- components
|
||||
#[derive(Debug, Eq, PartialEq, Clone, Hash)]
|
||||
|
||||
@@ -15,8 +15,8 @@ use super::{Id, IdSsh, IdTheme, SetupActivity, ViewLayout};
|
||||
use crate::config::themes::Theme;
|
||||
|
||||
impl SetupActivity {
|
||||
/// On <ESC>, if there are changes in the configuration, the quit dialog must be shown, otherwise
|
||||
/// we can exit without any problem
|
||||
/// On `ESC`, if there are changes in the configuration, the quit dialog
|
||||
/// must be shown; otherwise, we can exit without any problem.
|
||||
pub(super) fn action_on_esc(&mut self) {
|
||||
if self.config_changed() {
|
||||
self.mount_quit();
|
||||
|
||||
@@ -14,8 +14,9 @@ use super::{ConfigMsg, Msg};
|
||||
use crate::explorer::GroupDirs as GroupDirsEnum;
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
use crate::ui::activities::setup::{
|
||||
RADIO_PROTOCOL_FTP, RADIO_PROTOCOL_FTPS, RADIO_PROTOCOL_KUBE, RADIO_PROTOCOL_S3,
|
||||
RADIO_PROTOCOL_SCP, RADIO_PROTOCOL_SFTP, RADIO_PROTOCOL_SMB, RADIO_PROTOCOL_WEBDAV,
|
||||
RADIO_PROTOCOL_FTP, RADIO_PROTOCOL_FTPS, RADIO_PROTOCOL_GCS, RADIO_PROTOCOL_KUBE,
|
||||
RADIO_PROTOCOL_S3, RADIO_PROTOCOL_SCP, RADIO_PROTOCOL_SFTP, RADIO_PROTOCOL_SMB,
|
||||
RADIO_PROTOCOL_WEBDAV,
|
||||
};
|
||||
use crate::utils::parser::parse_bytesize;
|
||||
|
||||
@@ -73,7 +74,9 @@ impl DefaultProtocol {
|
||||
.color(Color::Cyan)
|
||||
.modifiers(BorderType::Rounded),
|
||||
)
|
||||
.choices(["SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "SMB", "WebDAV"])
|
||||
.choices([
|
||||
"SFTP", "SCP", "FTP", "FTPS", "Kube", "S3", "GCS", "SMB", "WebDAV",
|
||||
])
|
||||
.foreground(Color::Cyan)
|
||||
.rewind(true)
|
||||
.title(Title::from("Default protocol").alignment(HorizontalAlignment::Left))
|
||||
@@ -84,6 +87,7 @@ impl DefaultProtocol {
|
||||
FileTransferProtocol::Ftp(true) => RADIO_PROTOCOL_FTPS,
|
||||
FileTransferProtocol::Kube => RADIO_PROTOCOL_KUBE,
|
||||
FileTransferProtocol::AwsS3 => RADIO_PROTOCOL_S3,
|
||||
FileTransferProtocol::GoogleCloudStorage => RADIO_PROTOCOL_GCS,
|
||||
FileTransferProtocol::Smb => RADIO_PROTOCOL_SMB,
|
||||
FileTransferProtocol::WebDAV => RADIO_PROTOCOL_WEBDAV,
|
||||
}),
|
||||
|
||||
@@ -12,8 +12,8 @@ use tuirealm::state::{State, StateValue};
|
||||
use tuirealm::terminal::TerminalAdapter;
|
||||
|
||||
use super::{
|
||||
Context, Id, IdCommon, IdConfig, RADIO_PROTOCOL_KUBE, RADIO_PROTOCOL_WEBDAV, SetupActivity,
|
||||
ViewLayout, components,
|
||||
Context, Id, IdCommon, IdConfig, RADIO_PROTOCOL_GCS, RADIO_PROTOCOL_KUBE,
|
||||
RADIO_PROTOCOL_WEBDAV, SetupActivity, ViewLayout, components,
|
||||
};
|
||||
use crate::explorer::GroupDirs;
|
||||
use crate::filetransfer::FileTransferProtocol;
|
||||
@@ -272,6 +272,7 @@ impl SetupActivity {
|
||||
RADIO_PROTOCOL_FTPS => FileTransferProtocol::Ftp(true),
|
||||
RADIO_PROTOCOL_KUBE => FileTransferProtocol::Kube,
|
||||
RADIO_PROTOCOL_S3 => FileTransferProtocol::AwsS3,
|
||||
RADIO_PROTOCOL_GCS => FileTransferProtocol::GoogleCloudStorage,
|
||||
RADIO_PROTOCOL_SMB => FileTransferProtocol::Smb,
|
||||
RADIO_PROTOCOL_WEBDAV => FileTransferProtocol::WebDAV,
|
||||
_ => FileTransferProtocol::Sftp,
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user