mirror of
https://github.com/veeso/termscp.git
synced 2026-09-26 14:01:17 -07:00
Compare commits
15
Commits
98a1ce42dc
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4120311bf6 | ||
|
|
74c91c747c | ||
|
|
f54c50f6ef | ||
|
|
325c8ceee7 | ||
|
|
ebfaeb956c | ||
|
|
133ec898d8 | ||
|
|
1587d3d625 | ||
|
|
751f68f6d4 | ||
|
|
e966a83220 | ||
|
|
d99c76b43c | ||
|
|
7434c51063 | ||
|
|
9fbd617f88 | ||
|
|
2bbe560ec5 | ||
|
|
7472ca98a0 | ||
|
|
974b6d6917 |
+3
-12
@@ -2,10 +2,9 @@
|
||||
#
|
||||
# termscp pre-commit hook.
|
||||
#
|
||||
# Runs three gates before a commit is recorded:
|
||||
# 1. trufflehog -- scan the staged tree for verified/unknown secrets
|
||||
# 2. dprint -- check formatting (Markdown, TOML, YAML, Rust)
|
||||
# 3. cargo-deny -- advisories, licenses, bans, and sources
|
||||
# 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`.
|
||||
@@ -20,8 +19,6 @@ fail() {
|
||||
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
|
||||
command -v trufflehog >/dev/null 2>&1 || fail "trufflehog not found; install it or commit with --no-verify"
|
||||
|
||||
# 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)"
|
||||
@@ -31,12 +28,6 @@ if ! git diff --cached --quiet --diff-filter=ACMR --; then
|
||||
trap cleanup EXIT
|
||||
git checkout-index --all --prefix="$index_tree/"
|
||||
|
||||
echo "pre-commit: scanning staged tree for secrets"
|
||||
(
|
||||
cd "$index_tree"
|
||||
just scan_secrets . --fail-on-scan-errors
|
||||
)
|
||||
|
||||
echo "pre-commit: checking staged-tree formatting"
|
||||
(
|
||||
cd "$index_tree"
|
||||
|
||||
@@ -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".
|
||||
-->
|
||||
|
||||
@@ -21,6 +21,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ inputs.version }}
|
||||
prepared_ref: ${{ steps.prepared-ref.outputs.ref }}
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
@@ -44,9 +45,9 @@ jobs:
|
||||
git config user.email "christian.visintin@veeso.dev"
|
||||
|
||||
- name: Install git-cliff
|
||||
uses: taiki-e/install-action@37f7c5781271959fb65b6b35224e28652ff2b63d # v2.87.0
|
||||
uses: taiki-e/install-action@e67fa11c4b9316fa714ddf0abed07a0c3143b95b # v2.87.4
|
||||
with:
|
||||
tool: git-cliff
|
||||
tool: git-cliff,dprint
|
||||
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
@@ -66,6 +67,9 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: git-cliff --unreleased --tag "v$VERSION" --strip header -o RELEASE_NOTES.md
|
||||
|
||||
- name: Verify Cargo.lock is unchanged
|
||||
run: git diff --exit-code -- Cargo.lock
|
||||
|
||||
- name: Upload release notes
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
with:
|
||||
@@ -84,10 +88,22 @@ jobs:
|
||||
VERSION: ${{ inputs.version }}
|
||||
run: |
|
||||
rm -f RELEASE_NOTES.md
|
||||
git add -A
|
||||
git add -A -- . ':!Cargo.lock'
|
||||
git diff --cached --exit-code -- Cargo.lock
|
||||
git commit -m "chore: release v$VERSION"
|
||||
git push origin HEAD:main
|
||||
|
||||
- name: Export prepared ref
|
||||
id: prepared-ref
|
||||
env:
|
||||
DRY_RUN: ${{ inputs.dry_run }}
|
||||
run: |
|
||||
if [ "$DRY_RUN" = "true" ]; then
|
||||
echo "ref=$GITHUB_SHA" >> "$GITHUB_OUTPUT"
|
||||
else
|
||||
echo "ref=$(git rev-parse HEAD)" >> "$GITHUB_OUTPUT"
|
||||
fi
|
||||
|
||||
build:
|
||||
needs: prepare
|
||||
name: build-${{ matrix.target }}
|
||||
@@ -95,14 +111,12 @@ jobs:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
include:
|
||||
- target: x86_64-unknown-linux-gnu
|
||||
os: ubuntu-latest
|
||||
- target: x86_64-unknown-linux-musl
|
||||
os: ubuntu-24.04
|
||||
kind: linux
|
||||
deb_suffix: amd64
|
||||
- target: aarch64-unknown-linux-gnu
|
||||
- target: aarch64-unknown-linux-musl
|
||||
os: ubuntu-24.04-arm
|
||||
kind: linux
|
||||
deb_suffix: arm64
|
||||
- target: aarch64-apple-darwin
|
||||
os: macos-latest
|
||||
kind: macos
|
||||
@@ -125,58 +139,44 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ inputs.dry_run && github.sha || 'main' }}
|
||||
ref: ${{ needs.prepare.outputs.prepared_ref }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Prepare release version
|
||||
shell: bash
|
||||
run: |
|
||||
dist/release/bump_version.sh "$VERSION" "$(date +%F)"
|
||||
cargo update --package termscp --precise "$VERSION"
|
||||
cargo metadata --locked --no-deps --format-version 1 > /dev/null
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
- name: Install Rust target
|
||||
if: matrix.kind != 'windows'
|
||||
if: matrix.kind == 'macos'
|
||||
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)
|
||||
# ---- Linux: static musl build in a pinned Alpine container ----
|
||||
- name: Install cargo-deb (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
make \
|
||||
libgit2-dev \
|
||||
build-essential \
|
||||
pkg-config \
|
||||
libbsd-dev \
|
||||
libcap-dev \
|
||||
libcups2-dev \
|
||||
libgnutls28-dev \
|
||||
libicu-dev \
|
||||
libjansson-dev \
|
||||
libkeyutils-dev \
|
||||
libldap2-dev \
|
||||
zlib1g-dev \
|
||||
libpam0g-dev \
|
||||
libacl1-dev \
|
||||
libarchive-dev \
|
||||
flex \
|
||||
bison \
|
||||
libntirpc-dev \
|
||||
libtracker-sparql-3.0-dev \
|
||||
libglib2.0-dev \
|
||||
libdbus-1-dev \
|
||||
libsasl2-dev \
|
||||
libunistring-dev \
|
||||
cpanminus
|
||||
sudo cpanm Parse::Yapp::Driver
|
||||
cargo install cargo-deb
|
||||
run: cargo install cargo-deb --locked
|
||||
- name: Build (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: just build_release "$TARGET" "--features smb-vendored"
|
||||
run: just build_musl "$TARGET"
|
||||
- name: Build deb (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: just package_deb "$TARGET"
|
||||
run: cargo deb --locked --no-build --target "$TARGET" --features smb-vendored
|
||||
- name: Verify deb declares no runtime dependencies (Linux)
|
||||
if: matrix.kind == 'linux'
|
||||
run: |
|
||||
deb=$(ls target/"$TARGET"/debian/*.deb)
|
||||
depends=$(dpkg-deb -f "$deb" Depends)
|
||||
if [ -n "$depends" ]; then
|
||||
echo "static deb must have no Depends, got: $depends" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# ---- macOS ----
|
||||
- name: Install deps (macOS)
|
||||
@@ -229,7 +229,7 @@ jobs:
|
||||
if-no-files-found: error
|
||||
|
||||
publish-homebrew:
|
||||
needs: [prepare, build]
|
||||
needs: [prepare, build, release]
|
||||
runs-on: ubuntu-latest
|
||||
env:
|
||||
VERSION: ${{ needs.prepare.outputs.version }}
|
||||
@@ -255,12 +255,12 @@ jobs:
|
||||
cd "$GITHUB_WORKSPACE"
|
||||
SHA_MAC_ARM=$(cat dl/aarch64-apple-darwin.sha256)
|
||||
SHA_MAC_X64=$(cat dl/x86_64-apple-darwin.sha256)
|
||||
SHA_LIN_ARM=$(cat dl/aarch64-unknown-linux-gnu.sha256)
|
||||
SHA_LIN_X64=$(cat dl/x86_64-unknown-linux-gnu.sha256)
|
||||
SHA_LIN_ARM=$(cat dl/aarch64-unknown-linux-musl.sha256)
|
||||
SHA_LIN_X64=$(cat dl/x86_64-unknown-linux-musl.sha256)
|
||||
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"
|
||||
@@ -293,14 +293,12 @@ jobs:
|
||||
end
|
||||
|
||||
on_linux do
|
||||
depends_on "dbus"
|
||||
|
||||
on_arm do
|
||||
url "$BASE/termscp-v$VERSION-aarch64-unknown-linux-gnu.tar.gz"
|
||||
url "$BASE/termscp-v$VERSION-aarch64-unknown-linux-musl.tar.gz"
|
||||
sha256 "$SHA_LIN_ARM"
|
||||
end
|
||||
on_intel do
|
||||
url "$BASE/termscp-v$VERSION-x86_64-unknown-linux-gnu.tar.gz"
|
||||
url "$BASE/termscp-v$VERSION-x86_64-unknown-linux-musl.tar.gz"
|
||||
sha256 "$SHA_LIN_X64"
|
||||
end
|
||||
end
|
||||
@@ -336,9 +334,15 @@ jobs:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
ref: ${{ inputs.dry_run && github.sha || 'main' }}
|
||||
ref: ${{ needs.prepare.outputs.prepared_ref }}
|
||||
persist-credentials: true
|
||||
|
||||
- name: Prepare release version
|
||||
run: |
|
||||
dist/release/bump_version.sh "$VERSION" "$(date +%F)"
|
||||
cargo update --package termscp --precise "$VERSION"
|
||||
cargo metadata --locked --no-deps --format-version 1 > /dev/null
|
||||
|
||||
- name: Download build artifacts
|
||||
uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
with:
|
||||
@@ -399,10 +403,15 @@ jobs:
|
||||
steps:
|
||||
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: main
|
||||
ref: ${{ needs.prepare.outputs.prepared_ref }}
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
fetch-tags: true
|
||||
- name: Prepare release version
|
||||
run: |
|
||||
dist/release/bump_version.sh "$VERSION" "$(date +%F)"
|
||||
cargo update --package termscp --precise "$VERSION"
|
||||
cargo metadata --locked --no-deps --format-version 1 > /dev/null
|
||||
- name: Install just
|
||||
uses: extractions/setup-just@53165ef7e734c5c07cb06b3c8e7b647c5aa16db3 # v4
|
||||
|
||||
|
||||
@@ -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)
|
||||
+158
@@ -1,5 +1,163 @@
|
||||
# Changelog
|
||||
|
||||
## 1.2.0
|
||||
|
||||
Released on 2026-09-03
|
||||
|
||||
### Added
|
||||
|
||||
- **site:** add privacy policy page (#435)
|
||||
> Add a GDPR privacy policy covering Vercel hosting/server logs and
|
||||
> EU-hosted Umami analytics, and link it from the footer.
|
||||
- **gcs:** add Google Cloud Storage support (#443)
|
||||
> - feat(gcs): add Google Cloud Storage support
|
||||
- add support for all ssh2 config parameters.
|
||||
> Achieved by bumping `remotefs-ssh` to `0.9`.
|
||||
>
|
||||
> Added support for these parameters:
|
||||
>
|
||||
> - Compression
|
||||
> - Host key certificates
|
||||
> - CA signature algorithms
|
||||
> - keys to agents
|
||||
> - ProxyJump
|
||||
> - Server alive intervals
|
||||
> - Agent forwarding
|
||||
> - Remote forwarding
|
||||
> - Bind address
|
||||
> - Bind interface
|
||||
> - Connection attempts
|
||||
> - TCP Keepalive
|
||||
> - Accepted public key algos
|
||||
> - Certificate files
|
||||
- **ssh:** auto-fill ssh config parameters in auth form
|
||||
> Resolve SSH host parameters in auth forms and CLI connections while preserving explicit user, bookmark, and parsed alias values. Continue forwarding SSH config files for HostName and other SSH options, and document the precedence in English and Chinese.
|
||||
- **smb:** add SMB dialect selection and persistence (#445)
|
||||
> Support Auto, SMB1, SMB2, and SMB3 selection on Unix, bound negotiation to the selected dialect family, preserve legacy bookmarks as Auto, and document the new option. Windows keeps operating-system-managed negotiation.
|
||||
|
||||
### CI
|
||||
|
||||
- remove Codeberg mirror workflow. I do not support Codeberg mission anymore
|
||||
- migrate project automation to Just (#442)
|
||||
> - ci: migrate project automation to Just
|
||||
>
|
||||
> Centralize build, test, release, dependency, hook, and website commands in Just recipes. Pin workflow tooling, use the repository toolchain, and run the complete validation set in CI.
|
||||
>
|
||||
> - ci: codex being codex
|
||||
> - docs: update CLAUDE.md for just task runner migration
|
||||
>
|
||||
> Reflect the switch to just recipes for build/test/clippy/fmt, note
|
||||
> dprint replacing raw rustfmt, and add a cross-platform code requirement.
|
||||
>
|
||||
> - fix: resolve clippy warnings breaking CI on ubuntu and windows
|
||||
>
|
||||
> Use clone() instead of implicit to_string() on already-owned String
|
||||
> values, gate the windows-only unused make_file_at import behind
|
||||
> cfg(posix), and fix unused mut / manual assign-op in the windows-only
|
||||
> localhost test.
|
||||
>
|
||||
> - fix: fmt
|
||||
- **release:** build Linux artifacts as static musl binaries (#447)
|
||||
> Build Linux release artifacts as statically linked musl binaries for x86_64 and aarch64, update packaging and updater handling, and document the reduced runtime requirements.
|
||||
>
|
||||
> Keep release version preparation locked without refreshing dependencies or committing Cargo.lock.
|
||||
- **release:** add dprint
|
||||
|
||||
### Fixed
|
||||
|
||||
- **publish:** anchor include globs to crate root
|
||||
> Unanchored include patterns (LICENSE, README.md) matched at any depth
|
||||
> via gitignore-glob semantics, pulling 437 site/node_modules files into
|
||||
> the package and breaking cargo publish's dirty check.
|
||||
- print actual reason for failed host params collecting
|
||||
> previously, we didn't show any reason for failed collecting of host params in the auth form, but just a generic message
|
||||
|
||||
### Build
|
||||
|
||||
- **deps:** update aes-gcm to 0.11
|
||||
> Migrate nonce generation and parsing to the aes-gcm 0.11 API while preserving the encrypted payload format.
|
||||
- bump remotefs-smb 0.5.0 (#446)
|
||||
> - build: bump remotefs-smb 0.5.0
|
||||
> - fix(smb): windows build
|
||||
|
||||
## 1.2.0
|
||||
|
||||
Released on 2026-09-03
|
||||
|
||||
### Added
|
||||
|
||||
- **site:** add privacy policy page (#435)
|
||||
> Add a GDPR privacy policy covering Vercel hosting/server logs and
|
||||
> EU-hosted Umami analytics, and link it from the footer.
|
||||
- **gcs:** add Google Cloud Storage support (#443)
|
||||
> - feat(gcs): add Google Cloud Storage support
|
||||
- add support for all ssh2 config parameters.
|
||||
> Achieved by bumping `remotefs-ssh` to `0.9`.
|
||||
>
|
||||
> Added support for these parameters:
|
||||
>
|
||||
> - Compression
|
||||
> - Host key certificates
|
||||
> - CA signature algorithms
|
||||
> - keys to agents
|
||||
> - ProxyJump
|
||||
> - Server alive intervals
|
||||
> - Agent forwarding
|
||||
> - Remote forwarding
|
||||
> - Bind address
|
||||
> - Bind interface
|
||||
> - Connection attempts
|
||||
> - TCP Keepalive
|
||||
> - Accepted public key algos
|
||||
> - Certificate files
|
||||
- **ssh:** auto-fill ssh config parameters in auth form
|
||||
> Resolve SSH host parameters in auth forms and CLI connections while preserving explicit user, bookmark, and parsed alias values. Continue forwarding SSH config files for HostName and other SSH options, and document the precedence in English and Chinese.
|
||||
- **smb:** add SMB dialect selection and persistence (#445)
|
||||
> Support Auto, SMB1, SMB2, and SMB3 selection on Unix, bound negotiation to the selected dialect family, preserve legacy bookmarks as Auto, and document the new option. Windows keeps operating-system-managed negotiation.
|
||||
|
||||
### CI
|
||||
|
||||
- migrate project automation to Just (#442)
|
||||
> - ci: migrate project automation to Just
|
||||
>
|
||||
> Centralize build, test, release, dependency, hook, and website commands in Just recipes. Pin workflow tooling, use the repository toolchain, and run the complete validation set in CI.
|
||||
>
|
||||
> - ci: codex being codex
|
||||
> - docs: update CLAUDE.md for just task runner migration
|
||||
>
|
||||
> Reflect the switch to just recipes for build/test/clippy/fmt, note
|
||||
> dprint replacing raw rustfmt, and add a cross-platform code requirement.
|
||||
>
|
||||
> - fix: resolve clippy warnings breaking CI on ubuntu and windows
|
||||
>
|
||||
> Use clone() instead of implicit to_string() on already-owned String
|
||||
> values, gate the windows-only unused make_file_at import behind
|
||||
> cfg(posix), and fix unused mut / manual assign-op in the windows-only
|
||||
> localhost test.
|
||||
>
|
||||
> - fix: fmt
|
||||
- **release:** build Linux artifacts as static musl binaries (#447)
|
||||
> Build Linux release artifacts as statically linked musl binaries for x86_64 and aarch64, update packaging and updater handling, and document the reduced runtime requirements.
|
||||
>
|
||||
> Keep release version preparation locked without refreshing dependencies or committing Cargo.lock.
|
||||
|
||||
### Fixed
|
||||
|
||||
- **publish:** anchor include globs to crate root
|
||||
> Unanchored include patterns (LICENSE, README.md) matched at any depth
|
||||
> via gitignore-glob semantics, pulling 437 site/node_modules files into
|
||||
> the package and breaking cargo publish's dirty check.
|
||||
- print actual reason for failed host params collecting
|
||||
> previously, we didn't show any reason for failed collecting of host params in the auth form, but just a generic message
|
||||
|
||||
### Build
|
||||
|
||||
- **deps:** update aes-gcm to 0.11
|
||||
> Migrate nonce generation and parsing to the aes-gcm 0.11 API while preserving the encrypted payload format.
|
||||
- bump remotefs-smb 0.5.0 (#446)
|
||||
> - build: bump remotefs-smb 0.5.0
|
||||
> - fix(smb): windows build
|
||||
|
||||
## 1.1.1
|
||||
|
||||
Released on 2026-06-08
|
||||
|
||||
@@ -4,9 +4,9 @@ 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
|
||||
|
||||
@@ -97,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
|
||||
|
||||
|
||||
Generated
+1552
-1274
File diff suppressed because it is too large
Load Diff
+17
-13
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "termscp"
|
||||
version = "1.1.1"
|
||||
version = "1.2.0"
|
||||
authors = ["Christian Visintin <christian.visintin@veeso.dev>"]
|
||||
categories = ["command-line-utilities"]
|
||||
edition = "2024"
|
||||
@@ -10,7 +10,8 @@ keywords = ["terminal", "ftp", "scp", "sftp", "tui"]
|
||||
license = "MIT"
|
||||
readme = "README.md"
|
||||
repository = "https://github.com/veeso/termscp"
|
||||
description = "termscp is a feature rich terminal file transfer and explorer with support for SCP/SFTP/FTP/Kube/S3/WebDAV"
|
||||
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"
|
||||
@@ -24,6 +25,7 @@ termscp = { path = "/usr/bin/termscp" }
|
||||
[package.metadata.deb]
|
||||
maintainer = "Christian Visintin <christian.visintin@veeso.dev>"
|
||||
copyright = "2025, Christian Visintin <christian.visintin@veeso.dev>"
|
||||
depends = ""
|
||||
extended-description-file = "docs/misc/README.deb.txt"
|
||||
|
||||
[features]
|
||||
@@ -44,7 +46,7 @@ bytesize = "2"
|
||||
cbc = { version = "0.2", features = ["alloc"] }
|
||||
chrono = "0.4"
|
||||
content_inspector = "0.2"
|
||||
dirs = "6"
|
||||
dirs = "7"
|
||||
edit = "0.1"
|
||||
filetime = "0.2"
|
||||
keyring-core = "1"
|
||||
@@ -57,22 +59,24 @@ nucleo = "0.5"
|
||||
open = "5"
|
||||
rand = "0.10"
|
||||
regex = "1"
|
||||
remotefs = "0.3"
|
||||
remotefs-aws-s3 = "0.4"
|
||||
remotefs-kube = "0.4"
|
||||
remotefs-smb = { version = "0.3", optional = true }
|
||||
remotefs-ssh = { version = "0.8", default-features = false, features = ["russh"] }
|
||||
remotefs-webdav = "0.2"
|
||||
remotefs = "1"
|
||||
remotefs-aws-s3 = { version = "1", features = ["tokio"] }
|
||||
remotefs-gcs = { version = "1", features = ["tokio"] }
|
||||
remotefs-kube = { version = "1", features = ["tokio"] }
|
||||
remotefs-smb = { version = "1", default-features = false, optional = true, features = ["find", "pavao"] }
|
||||
remotefs-ssh = { version = "1", default-features = false, features = ["russh"] }
|
||||
remotefs-webdav = { version = "1", features = ["tokio"] }
|
||||
rpassword = "7"
|
||||
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"] }
|
||||
tokio = { version = "1", features = ["rt-multi-thread"] }
|
||||
toml = "1"
|
||||
tui-realm-stdlib = "4"
|
||||
tui-term = "0.3"
|
||||
@@ -85,11 +89,11 @@ wildmatch = "2"
|
||||
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 = "1", features = ["native-tls", "native-tls-vendored"] }
|
||||
uzers = "0.12"
|
||||
|
||||
[target."cfg(target_family = \"windows\")".dependencies]
|
||||
remotefs-ftp = { version = "0.4", features = ["native-tls"] }
|
||||
remotefs-ftp = { version = "1", features = ["native-tls"] }
|
||||
|
||||
[target."cfg(target_os = \"macos\")".dependencies]
|
||||
apple-native-keyring-store = { version = "1", features = ["keychain"] }
|
||||
|
||||
@@ -2,6 +2,7 @@ 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"
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||

|
||||
|
||||
@@ -49,6 +49,7 @@ Termscp is a feature rich terminal file transfer and explorer, with support for
|
||||
- **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
|
||||
@@ -116,6 +117,12 @@ For more information or other platforms, please visit [termscp.rs](https://terms
|
||||
|
||||
### Requirements ❗
|
||||
|
||||
The official Linux binaries and `.deb` packages are statically linked against
|
||||
musl and have **no runtime requirements**: they run on any distribution and
|
||||
any glibc version.
|
||||
|
||||
These are only required to build termscp from source:
|
||||
|
||||
- **Linux** users:
|
||||
- libdbus-1
|
||||
- pkg-config
|
||||
|
||||
@@ -33,17 +33,9 @@ ignore = [
|
||||
{ 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" },
|
||||
# russh-keys retains an older russh-cryptovec line and cannot select the
|
||||
# fixed 0.60.3 release without an upstream dependency update.
|
||||
{ id = "RUSTSEC-2026-0153", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade remotefs-ssh/russh-keys to russh-cryptovec >=0.60.3" },
|
||||
# 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" },
|
||||
# The AWS HTTP stack retains rustls 0.21 and rustls-webpki 0.101, which cannot
|
||||
# select the fixed rustls-webpki release.
|
||||
{ id = "RUSTSEC-2026-0098", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade AWS HTTP stack to fixed rustls-webpki" },
|
||||
{ id = "RUSTSEC-2026-0099", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade AWS HTTP stack to fixed rustls-webpki" },
|
||||
{ id = "RUSTSEC-2026-0104", reason = "owner: termscp maintainers; review by 2026-12-31; upgrade AWS HTTP stack to fixed rustls-webpki" },
|
||||
]
|
||||
|
||||
[licenses]
|
||||
|
||||
Vendored
+1
-1
@@ -3,7 +3,7 @@
|
||||
<package xmlns="http://schemas.microsoft.com/packaging/2015/06/nuspec.xsd">
|
||||
<metadata>
|
||||
<id>termscp</id>
|
||||
<version>1.1.1</version>
|
||||
<version>1.2.0</version>
|
||||
<title>termSCP</title>
|
||||
<authors>Christian Visintin</authors>
|
||||
<owners>Christian Visintin</owners>
|
||||
|
||||
+2
-2
@@ -5,10 +5,10 @@ $toolsDir = "$(Split-Path -parent $MyInvocation.MyCommand.Definition)"
|
||||
$is_arm64 = $env:PROCESSOR_ARCHITECTURE -eq 'ARM64' -or $env:PROCESSOR_ARCHITEW6432 -eq 'ARM64'
|
||||
|
||||
if ($is_arm64) {
|
||||
$url = 'https://github.com/veeso/termscp/releases/download/v1.1.1/termscp-v1.1.1-msvc.zip'
|
||||
$url = 'https://github.com/veeso/termscp/releases/download/v1.2.0/termscp-v1.2.0-msvc.zip'
|
||||
$checksum = 'f6ad6c62f1578562f9af4bcee93bd4cc429cb52219c3636359b008db8789587e'
|
||||
} else {
|
||||
$url = 'https://github.com/veeso/termscp/releases/download/v1.1.1/termscp-v1.1.1-x86_64-pc-windows-msvc.zip'
|
||||
$url = 'https://github.com/veeso/termscp/releases/download/v1.2.0/termscp-v1.2.0-x86_64-pc-windows-msvc.zip'
|
||||
$checksum = 'd7796081b6f67b82acfa94557aa6852d12a33daab3cca6490660b20e42752005'
|
||||
}
|
||||
|
||||
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
#!/usr/bin/env sh
|
||||
# Builds a static musl termscp release binary for the given target triple
|
||||
# inside a pinned Alpine container.
|
||||
#
|
||||
# Usage: dist/release/build_musl.sh <target-triple>
|
||||
set -eu
|
||||
|
||||
IMAGE="rust:1.98-alpine3.22"
|
||||
|
||||
TARGET="${1:-}"
|
||||
if [ -z "$TARGET" ]; then
|
||||
echo "usage: $0 <target-triple>" >&2
|
||||
exit 2
|
||||
fi
|
||||
|
||||
case "$TARGET" in
|
||||
x86_64-unknown-linux-musl) PLATFORM="linux/amd64" ;;
|
||||
aarch64-unknown-linux-musl) PLATFORM="linux/arm64" ;;
|
||||
*)
|
||||
echo "unsupported target: $TARGET" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
|
||||
WORKSPACE="$(CDPATH='' cd -- "$(dirname -- "$0")/../.." && pwd)"
|
||||
|
||||
# The container appends a [patch.crates-io] section to Cargo.toml and updates
|
||||
# Cargo.lock; keep byte-exact copies so the workspace is clean afterwards.
|
||||
BACKUP_DIR="$(mktemp -d)"
|
||||
cp -p "$WORKSPACE/Cargo.toml" "$BACKUP_DIR/Cargo.toml"
|
||||
cp -p "$WORKSPACE/Cargo.lock" "$BACKUP_DIR/Cargo.lock"
|
||||
|
||||
restore_manifests() {
|
||||
cp -p "$BACKUP_DIR/Cargo.toml" "$WORKSPACE/Cargo.toml"
|
||||
cp -p "$BACKUP_DIR/Cargo.lock" "$WORKSPACE/Cargo.lock"
|
||||
rm -rf "$BACKUP_DIR"
|
||||
}
|
||||
trap restore_manifests EXIT
|
||||
|
||||
HOST_UID="$(id -u)"
|
||||
HOST_GID="$(id -g)"
|
||||
export TARGET HOST_UID HOST_GID
|
||||
|
||||
docker run --rm \
|
||||
--platform "$PLATFORM" \
|
||||
--env TARGET \
|
||||
--env HOST_UID \
|
||||
--env HOST_GID \
|
||||
--volume "$WORKSPACE:/work" \
|
||||
--workdir /work \
|
||||
"$IMAGE" \
|
||||
sh /work/dist/release/build_musl_container.sh
|
||||
+143
@@ -0,0 +1,143 @@
|
||||
#!/usr/bin/env sh
|
||||
# Builds a static musl termscp binary. Runs INSIDE the Alpine container
|
||||
# started by dist/release/build_musl.sh; /work is the mounted workspace.
|
||||
#
|
||||
# Required environment: TARGET, HOST_UID, HOST_GID.
|
||||
set -eux
|
||||
|
||||
NETTLE_VERSION="3.10.1"
|
||||
GNUTLS_VERSION="3.8.13"
|
||||
PAVAO_SRC_VERSION="4.24.6"
|
||||
|
||||
cleanup() {
|
||||
chown -R "$HOST_UID:$HOST_GID" /work
|
||||
}
|
||||
trap cleanup EXIT
|
||||
|
||||
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"
|
||||
|
||||
# -- static nettle (GnuTLS crypto backend); mini-gmp avoids a GMP dependency
|
||||
mkdir -p /tmp/native
|
||||
wget -q "https://ftp.gnu.org/gnu/nettle/nettle-$NETTLE_VERSION.tar.gz" \
|
||||
-O /tmp/native/nettle.tar.gz
|
||||
tar -xzf /tmp/native/nettle.tar.gz -C /tmp/native
|
||||
cd "/tmp/native/nettle-$NETTLE_VERSION"
|
||||
./configure \
|
||||
--prefix=/tmp/native/nettle \
|
||||
--disable-shared \
|
||||
--enable-static \
|
||||
--disable-documentation \
|
||||
--enable-mini-gmp
|
||||
make -j"$(getconf _NPROCESSORS_ONLN)"
|
||||
make install
|
||||
|
||||
# -- static GnuTLS; every optional backend is disabled so nothing links
|
||||
# against a shared library
|
||||
wget -q "https://www.gnupg.org/ftp/gcrypt/gnutls/v3.8/gnutls-$GNUTLS_VERSION.tar.xz" \
|
||||
-O /tmp/native/gnutls.tar.xz
|
||||
tar -xf /tmp/native/gnutls.tar.xz -C /tmp/native
|
||||
cd "/tmp/native/gnutls-$GNUTLS_VERSION"
|
||||
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
|
||||
|
||||
# -- flatten gnutls.pc: pkg-config must hand the linker the static archives
|
||||
# directly, with no Requires.private chain to resolve
|
||||
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
|
||||
|
||||
# -- pavao-src: Samba's replacement library omits two sources that musl needs
|
||||
cd /work
|
||||
PAVAO_SRC=$(find "${CARGO_HOME:-/usr/local/cargo}/registry/src" \
|
||||
-type d -name "pavao-src-$PAVAO_SRC_VERSION" -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@$PAVAO_SRC_VERSION"
|
||||
|
||||
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
|
||||
|
||||
# -- prove the binary is static: no interpreter, no shared libraries
|
||||
file "target/$TARGET/release/termscp"
|
||||
if readelf -l "target/$TARGET/release/termscp" > /tmp/program-headers.txt; then
|
||||
cat /tmp/program-headers.txt
|
||||
else
|
||||
status=$?
|
||||
cat /tmp/program-headers.txt
|
||||
exit "$status"
|
||||
fi
|
||||
if readelf -d "target/$TARGET/release/termscp" > /tmp/dynamic-section.txt; then
|
||||
cat /tmp/dynamic-section.txt
|
||||
else
|
||||
status=$?
|
||||
cat /tmp/dynamic-section.txt
|
||||
exit "$status"
|
||||
fi
|
||||
if grep -q INTERP /tmp/program-headers.txt; then
|
||||
exit 1
|
||||
fi
|
||||
if grep -q NEEDED /tmp/dynamic-section.txt; then
|
||||
exit 1
|
||||
fi
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
|
||||
@@ -44,9 +44,28 @@ Install termscp from the official repositories:
|
||||
pacman -S termscp
|
||||
```
|
||||
|
||||
## Official binaries
|
||||
|
||||
Official release binaries are published for these targets:
|
||||
|
||||
- GNU/Linux:
|
||||
- `x86_64-unknown-linux-musl`
|
||||
- `aarch64-unknown-linux-musl`
|
||||
- macOS:
|
||||
- `x86_64-apple-darwin`
|
||||
- `aarch64-apple-darwin`
|
||||
- Windows:
|
||||
- `x86_64-pc-windows-msvc`
|
||||
- `aarch64-pc-windows-msvc`
|
||||
|
||||
The Linux binaries, and the `.deb` package built from them, are statically
|
||||
linked against musl. They have no runtime dependencies: they run on any Linux
|
||||
distribution and any glibc version, with no system packages to install.
|
||||
|
||||
## Requirements
|
||||
|
||||
The following system dependencies are required to run termscp.
|
||||
The official binaries do not require these dependencies. They are needed only
|
||||
to build termscp from source, for example with `cargo install termscp`:
|
||||
|
||||
- Linux users:
|
||||
- libdbus-1
|
||||
|
||||
+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
|
||||
|
||||
@@ -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** 操作系统。
|
||||
|
||||

|
||||
|
||||
@@ -49,6 +49,7 @@ termscp 是一个功能丰富的终端文件浏览和传输工具,支持 SCP/S
|
||||
- **FTP** 和 **FTPS**
|
||||
- **Kube**
|
||||
- **S3**
|
||||
- **Google Cloud Storage (GCS)**
|
||||
- **SMB**
|
||||
- **WebDAV**
|
||||
- 🖥 使用便捷的 UI 在远程和本地文件系统上浏览和操作
|
||||
@@ -116,6 +117,10 @@ pacman -S termscp
|
||||
|
||||
### 依赖 ❗
|
||||
|
||||
官方 Linux 二进制文件和 `.deb` 包静态链接了 musl,**没有任何运行时依赖**:可在任意发行版、任意 glibc 版本上运行。
|
||||
|
||||
以下依赖仅在从源码构建 termscp 时需要:
|
||||
|
||||
- **Linux** 用户:
|
||||
- libdbus-1
|
||||
- pkg-config
|
||||
|
||||
@@ -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 = "."
|
||||
|
||||
@@ -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 选项。
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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 地址语法:
|
||||
|
||||
|
||||
@@ -42,9 +42,25 @@ pkgin install termscp
|
||||
pacman -S termscp
|
||||
```
|
||||
|
||||
## 官方二进制文件
|
||||
|
||||
官方发布的二进制文件支持以下目标:
|
||||
|
||||
- GNU/Linux:
|
||||
- `x86_64-unknown-linux-musl`
|
||||
- `aarch64-unknown-linux-musl`
|
||||
- macOS:
|
||||
- `x86_64-apple-darwin`
|
||||
- `aarch64-apple-darwin`
|
||||
- Windows:
|
||||
- `x86_64-pc-windows-msvc`
|
||||
- `aarch64-pc-windows-msvc`
|
||||
|
||||
Linux 二进制文件以及由其构建的 `.deb` 包都静态链接了 musl。它们没有任何运行时依赖:可在任意 Linux 发行版、任意 glibc 版本上运行,无需安装任何系统软件包。
|
||||
|
||||
## 系统要求
|
||||
|
||||
运行 termscp 需要以下系统依赖。
|
||||
官方二进制文件不需要以下依赖。它们仅在从源码构建 termscp 时才需要,例如使用 `cargo install termscp`:
|
||||
|
||||
- Linux 用户:
|
||||
- libdbus-1
|
||||
|
||||
+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">
|
||||
|
||||
+1
-1
@@ -26,7 +26,7 @@
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[string]$Version = "1.1.1",
|
||||
[string]$Version = "1.2.0",
|
||||
[string]$InstallDir = "$env:LOCALAPPDATA\Programs\termscp",
|
||||
[Alias("Yes")]
|
||||
[switch]$Force
|
||||
|
||||
+1
-1
@@ -9,7 +9,7 @@
|
||||
# -f, -y, --force, --yes
|
||||
# Skip the confirmation prompt during installation
|
||||
|
||||
TERMSCP_VERSION="1.1.1"
|
||||
TERMSCP_VERSION="1.2.0"
|
||||
GITHUB_URL="https://github.com/veeso/termscp/releases/download/v${TERMSCP_VERSION}"
|
||||
DEB_URL_AMD64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_amd64.deb"
|
||||
DEB_URL_AARCH64="${GITHUB_URL}/termscp_${TERMSCP_VERSION}-1_arm64.deb"
|
||||
|
||||
@@ -17,6 +17,11 @@ build_crates_release:
|
||||
build_release target features="":
|
||||
cargo build --locked --release --target {{ target }} {{ features }}
|
||||
|
||||
# Build a static musl Linux release binary in a pinned Alpine container
|
||||
[group('build')]
|
||||
build_musl target:
|
||||
dist/release/build_musl.sh {{ target }}
|
||||
|
||||
# Package an already-built Linux release as a Debian package
|
||||
[group('build')]
|
||||
package_deb target:
|
||||
|
||||
@@ -42,3 +42,4 @@ changelog version:
|
||||
rm -f "$secfile"
|
||||
mv "$tmp" CHANGELOG.md
|
||||
echo "CHANGELOG.md updated for $tag"
|
||||
just fmt CHANGELOG.md
|
||||
|
||||
@@ -25,11 +25,6 @@ doc args="":
|
||||
deny args="":
|
||||
cargo deny check {{ args }}
|
||||
|
||||
# Scan for secrets with trufflehog (defaults to the whole working tree)
|
||||
[group('code_check')]
|
||||
scan_secrets *args=".":
|
||||
trufflehog filesystem {{ args }} --results=verified,unknown --fail --no-update
|
||||
|
||||
# Point git at the tracked .githooks directory (installs the pre-commit hook)
|
||||
[group('code_check')]
|
||||
setup_githooks:
|
||||
@@ -40,7 +35,9 @@ setup_githooks:
|
||||
[group('code_check')]
|
||||
check_install_scripts:
|
||||
sh -n install.sh
|
||||
shellcheck install.sh
|
||||
sh -n dist/release/build_musl.sh
|
||||
sh -n dist/release/build_musl_container.sh
|
||||
shellcheck install.sh dist/release/build_musl.sh dist/release/build_musl_container.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 \
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
# Publish the termscp crate
|
||||
[group('publish')]
|
||||
publish_crate args="":
|
||||
cargo publish --locked --features smb-vendored {{ args }}
|
||||
cargo publish --locked --features smb {{ 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 }}
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
export const DOCS_URL = "https://docs.termscp.rs";
|
||||
export const GITHUB_URL = "https://github.com/veeso/termscp";
|
||||
export const VERSION = "1.1.1";
|
||||
export const VERSION = "1.2.0";
|
||||
|
||||
@@ -72,9 +72,12 @@ const methods = [
|
||||
Update anytime with <span class="text-green">termscp --update</span>.
|
||||
</p>
|
||||
<p class="mt-2 text-sm text-overlay">
|
||||
Linux build deps: <span class="text-text">libdbus-1</span>, <span
|
||||
Official Linux binaries and .deb packages are statically linked against <span
|
||||
class="text-text">musl</span
|
||||
>: no runtime dependencies, any distribution, any glibc version. Building
|
||||
from source still needs <span class="text-text">libdbus-1</span>, <span
|
||||
class="text-text">pkg-config</span
|
||||
>, <span class="text-text">libsmbclient</span>. More details in the <a
|
||||
> and <span class="text-text">libsmbclient</span>. More details in the <a
|
||||
href={DOCS_URL}
|
||||
class="text-blue hover:underline">docs</a
|
||||
>.
|
||||
|
||||
+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));
|
||||
}
|
||||
}
|
||||
|
||||
+191
-2
@@ -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]
|
||||
@@ -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#"
|
||||
|
||||
+34
-46
@@ -565,20 +565,18 @@ mod tests {
|
||||
let explorer: FileExplorer = FileExplorer::default();
|
||||
// Create fs entry
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = File::new(
|
||||
"/bar.txt",
|
||||
Metadata::default()
|
||||
.accessed(t)
|
||||
.created(t)
|
||||
.modified(t)
|
||||
.file_type(FileType::File)
|
||||
.uid(0)
|
||||
.gid(0)
|
||||
.mode(UnixPex::from(0o644))
|
||||
.size(8192),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
explorer.fmt_file(&entry),
|
||||
@@ -690,47 +688,37 @@ mod tests {
|
||||
|
||||
fn make_fs_entry(name: &str, is_dir: bool) -> File {
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let metadata = Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: if is_dir {
|
||||
let metadata = Metadata::default()
|
||||
.accessed(t)
|
||||
.created(t)
|
||||
.modified(t)
|
||||
.file_type(if is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
},
|
||||
symlink: None,
|
||||
gid: Some(0),
|
||||
uid: Some(0),
|
||||
mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })),
|
||||
size: 64,
|
||||
};
|
||||
File {
|
||||
path: PathBuf::from(name),
|
||||
metadata,
|
||||
}
|
||||
})
|
||||
.gid(0)
|
||||
.uid(0)
|
||||
.mode(UnixPex::from(if is_dir { 0o755 } else { 0o644 }))
|
||||
.size(64);
|
||||
File::new(name, metadata)
|
||||
}
|
||||
|
||||
fn make_fs_entry_with_size(name: &str, is_dir: bool, size: usize) -> File {
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let metadata = Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: if is_dir {
|
||||
let metadata = Metadata::default()
|
||||
.accessed(t)
|
||||
.created(t)
|
||||
.modified(t)
|
||||
.file_type(if is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
},
|
||||
symlink: None,
|
||||
gid: Some(0),
|
||||
uid: Some(0),
|
||||
mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })),
|
||||
size: size as u64,
|
||||
};
|
||||
File {
|
||||
path: PathBuf::from(name),
|
||||
metadata,
|
||||
}
|
||||
})
|
||||
.gid(0)
|
||||
.uid(0)
|
||||
.mode(UnixPex::from(if is_dir { 0o755 } else { 0o644 }))
|
||||
.size(size as u64);
|
||||
File::new(name, metadata)
|
||||
}
|
||||
}
|
||||
|
||||
+231
-255
@@ -363,8 +363,10 @@ impl Formatter {
|
||||
) -> String {
|
||||
if fsentry.is_file() {
|
||||
// Get byte size
|
||||
let size: ByteSize = ByteSize(fsentry.metadata().size);
|
||||
let mut fmt = size.display().si().to_string();
|
||||
let mut fmt = fsentry.metadata().size.map_or_else(
|
||||
|| String::from("?"),
|
||||
|size| ByteSize(size).display().si().to_string(),
|
||||
);
|
||||
// pad with up to len 10
|
||||
let pad = 10usize.saturating_sub(fmt.len());
|
||||
for _ in 0..pad {
|
||||
@@ -534,7 +536,6 @@ impl Formatter {
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
@@ -542,26 +543,61 @@ mod tests {
|
||||
|
||||
use super::*;
|
||||
|
||||
#[expect(
|
||||
clippy::too_many_arguments,
|
||||
reason = "The fixture parameters mirror the optional remotefs metadata fields."
|
||||
)]
|
||||
fn fixture(
|
||||
path: &str,
|
||||
file_type: FileType,
|
||||
size: Option<u64>,
|
||||
symlink: Option<&str>,
|
||||
uid: Option<u32>,
|
||||
gid: Option<u32>,
|
||||
mode: Option<UnixPex>,
|
||||
timestamp: Option<SystemTime>,
|
||||
) -> File {
|
||||
let mut metadata = Metadata::default().file_type(file_type);
|
||||
if let Some(size) = size {
|
||||
metadata = metadata.size(size);
|
||||
}
|
||||
if let Some(symlink) = symlink {
|
||||
metadata = metadata.symlink(symlink);
|
||||
}
|
||||
if let Some(uid) = uid {
|
||||
metadata = metadata.uid(uid);
|
||||
}
|
||||
if let Some(gid) = gid {
|
||||
metadata = metadata.gid(gid);
|
||||
}
|
||||
if let Some(mode) = mode {
|
||||
metadata = metadata.mode(mode);
|
||||
}
|
||||
if let Some(timestamp) = timestamp {
|
||||
metadata = metadata
|
||||
.accessed(timestamp)
|
||||
.created(timestamp)
|
||||
.modified(timestamp);
|
||||
}
|
||||
File::new(path, metadata)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_fs_explorer_formatter_callchain() {
|
||||
// Make a dummy formatter
|
||||
let dummy_formatter: Formatter = Formatter::new("");
|
||||
// Make a dummy entry
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let dummy_entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let dummy_entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
let prefix: String = String::from("h");
|
||||
let mut callchain: CallChainBlock = CallChainBlock::new(dummy_fmt, prefix, None, None);
|
||||
assert!(callchain.next_block.is_none());
|
||||
@@ -589,20 +625,16 @@ mod tests {
|
||||
let formatter: Formatter = Formatter::default();
|
||||
// Experiments :D
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -620,20 +652,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// Elide name
|
||||
let entry = File {
|
||||
path: PathBuf::from("/piroparoporoperoperupupu.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/piroparoporoperoperupupu.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -651,20 +679,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// No pex
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
None,
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -682,20 +706,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// No user
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: Some(0),
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -720,20 +740,16 @@ mod tests {
|
||||
let formatter: Formatter = Formatter::default();
|
||||
// Experiments :D
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/home/cvisintin/projects"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Directory,
|
||||
size: 4096,
|
||||
symlink: None,
|
||||
uid: Some(0),
|
||||
gid: Some(0),
|
||||
mode: Some(UnixPex::from(0o755)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/home/cvisintin/projects",
|
||||
FileType::Directory,
|
||||
Some(4096),
|
||||
None,
|
||||
Some(0),
|
||||
Some(0),
|
||||
Some(UnixPex::from(0o755)),
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -751,20 +767,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// No pex, no user
|
||||
let entry = File {
|
||||
path: PathBuf::from("/home/cvisintin/projects"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Directory,
|
||||
size: 4096,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: Some(0),
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/home/cvisintin/projects",
|
||||
FileType::Directory,
|
||||
Some(4096),
|
||||
None,
|
||||
None,
|
||||
Some(0),
|
||||
None,
|
||||
Some(t),
|
||||
);
|
||||
#[cfg(posix)]
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
@@ -790,20 +802,16 @@ mod tests {
|
||||
);
|
||||
// Directory (with symlink)
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/home/cvisintin/projects"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Symlink,
|
||||
size: 4096,
|
||||
symlink: Some(PathBuf::from("project.info")),
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o755)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/home/cvisintin/projects",
|
||||
FileType::Symlink,
|
||||
Some(4096),
|
||||
Some("project.info"),
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o755)),
|
||||
Some(t),
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
@@ -814,20 +822,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// Directory without symlink
|
||||
let entry = File {
|
||||
path: PathBuf::from("/home/cvisintin/projects"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Directory,
|
||||
size: 4096,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o755)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/home/cvisintin/projects",
|
||||
FileType::Directory,
|
||||
Some(4096),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o755)),
|
||||
Some(t),
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
@@ -838,20 +842,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// File with symlink
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Symlink,
|
||||
size: 8192,
|
||||
symlink: Some(PathBuf::from("project.info")),
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::Symlink,
|
||||
Some(8192),
|
||||
Some("project.info"),
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
@@ -862,20 +862,16 @@ mod tests {
|
||||
)
|
||||
);
|
||||
// File without symlink
|
||||
let entry = File {
|
||||
path: PathBuf::from("/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/bar.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry),
|
||||
format!(
|
||||
@@ -891,20 +887,16 @@ mod tests {
|
||||
#[cfg(posix)]
|
||||
fn should_fmt_path() {
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/a/b/c/bar.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Symlink,
|
||||
size: 8192,
|
||||
symlink: Some(PathBuf::from("project.info")),
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/a/b/c/bar.txt",
|
||||
FileType::Symlink,
|
||||
Some(8192),
|
||||
Some("project.info"),
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("File path: {PATH}");
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry).as_str(),
|
||||
@@ -923,20 +915,16 @@ mod tests {
|
||||
#[cfg(posix)]
|
||||
fn should_fmt_utf8_path() {
|
||||
let t: SystemTime = SystemTime::now();
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/a/b/c/россия"),
|
||||
metadata: Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: FileType::Symlink,
|
||||
size: 8192,
|
||||
symlink: Some(PathBuf::from("project.info")),
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: Some(UnixPex::from(0o644)),
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/a/b/c/россия",
|
||||
FileType::Symlink,
|
||||
Some(8192),
|
||||
Some("project.info"),
|
||||
None,
|
||||
None,
|
||||
Some(UnixPex::from(0o644)),
|
||||
Some(t),
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("File path: {PATH}");
|
||||
assert_eq!(
|
||||
formatter.fmt(&entry).as_str(),
|
||||
@@ -948,100 +936,88 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn should_fmt_short_ascii_name() {
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/foo.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: None,
|
||||
created: None,
|
||||
modified: None,
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/foo.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("{NAME:8}");
|
||||
assert_eq!(formatter.fmt(&entry).as_str(), "foo.txt ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fmt_exceeding_length_ascii_name() {
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/christian-visintin.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: None,
|
||||
created: None,
|
||||
modified: None,
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/christian-visintin.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("{NAME:8}");
|
||||
assert_eq!(formatter.fmt(&entry).as_str(), "christi…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fmt_short_utf8_name() {
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/россия"),
|
||||
metadata: Metadata {
|
||||
accessed: None,
|
||||
created: None,
|
||||
modified: None,
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/россия",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("{NAME:8}");
|
||||
assert_eq!(formatter.fmt(&entry).as_str(), "россия ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fmt_long_utf8_name() {
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵"),
|
||||
metadata: Metadata {
|
||||
accessed: None,
|
||||
created: None,
|
||||
modified: None,
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵喵",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("{NAME:8}");
|
||||
assert_eq!(formatter.fmt(&entry).as_str(), "喵喵喵喵喵喵喵…");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_fmt_unknown_file_size() {
|
||||
let entry = File::new("/tmp/unknown", Metadata::default());
|
||||
let formatter = Formatter::new("{SIZE}");
|
||||
|
||||
assert_eq!(formatter.fmt(&entry), "? ");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_ignore_unknown_formatter_keys() {
|
||||
let entry = File {
|
||||
path: PathBuf::from("/tmp/foo.txt"),
|
||||
metadata: Metadata {
|
||||
accessed: None,
|
||||
created: None,
|
||||
modified: None,
|
||||
file_type: FileType::File,
|
||||
size: 8192,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
gid: None,
|
||||
mode: None,
|
||||
},
|
||||
};
|
||||
let entry = fixture(
|
||||
"/tmp/foo.txt",
|
||||
FileType::File,
|
||||
Some(8192),
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
None,
|
||||
);
|
||||
let formatter: Formatter = Formatter::new("before {UNKNOWN:12} after {NAME:8}");
|
||||
|
||||
assert_eq!(formatter.fmt(&entry).as_str(), "before after foo.txt ");
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
mod host_bridge_builder;
|
||||
pub mod params;
|
||||
mod remotefs_builder;
|
||||
mod wrapper;
|
||||
|
||||
// -- export types
|
||||
pub use host_bridge_builder::HostBridgeBuilder;
|
||||
@@ -17,6 +18,7 @@ pub use remotefs_builder::RemoteFsBuilder;
|
||||
pub enum FileTransferProtocol {
|
||||
AwsS3,
|
||||
Ftp(bool), // Bool is for secure (true => ftps)
|
||||
GoogleCloudStorage,
|
||||
Kube,
|
||||
Scp,
|
||||
Sftp,
|
||||
@@ -37,6 +39,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 +56,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 +147,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 +173,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() {
|
||||
|
||||
@@ -3,31 +3,36 @@
|
||||
//! Remotefs client builder
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use remotefs::RemoteFs;
|
||||
use remotefs_aws_s3::AwsS3Fs;
|
||||
use remotefs_ftp::FtpFs;
|
||||
use remotefs_kube::KubeMultiPodFs as KubeFs;
|
||||
use remotefs_gcs::credentials::service_account;
|
||||
use remotefs_gcs::{GoogleCloudStorageCredentials, GoogleCloudStorageFs};
|
||||
use remotefs_kube::KubeMultiPodFs;
|
||||
#[cfg(smb_unix)]
|
||||
use remotefs_smb::SmbOptions;
|
||||
#[cfg(smb)]
|
||||
use remotefs_smb::{SmbCredentials, SmbFs};
|
||||
use remotefs_smb::{
|
||||
PavaoSmbCredentials as SmbCredentials, PavaoSmbFs as SmbFs, 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,
|
||||
NoCheckServerKey, RusshScpFs, RusshSftpFs, 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::wrapper::RuntimeRemoteFs;
|
||||
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)?))
|
||||
}
|
||||
@@ -62,7 +71,7 @@ impl RemoteFsBuilder {
|
||||
Ok(Box::new(Self::smb_client(params)?))
|
||||
}
|
||||
(FileTransferProtocol::WebDAV, ProtocolParams::WebDAV(params)) => {
|
||||
Ok(Box::new(Self::webdav_client(params)))
|
||||
Ok(Box::new(Self::webdav_client(params)?))
|
||||
}
|
||||
(protocol, params) => {
|
||||
error!("Invalid params for protocol '{:?}'", protocol);
|
||||
@@ -74,16 +83,11 @@ impl RemoteFsBuilder {
|
||||
}
|
||||
|
||||
/// Build aws s3 client from parameters
|
||||
fn aws_s3_client(params: AwsS3Params) -> Result<AwsS3Fs, String> {
|
||||
let rt = Arc::new(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Unable to create tokio runtime: {e}"))?,
|
||||
);
|
||||
let mut client =
|
||||
AwsS3Fs::new(params.bucket_name, &rt).new_path_style(params.new_path_style);
|
||||
fn aws_s3_client(params: AwsS3Params) -> Result<RuntimeRemoteFs, String> {
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
|
||||
let mut client = AwsS3Fs::new(params.bucket_name).new_path_style(params.new_path_style);
|
||||
|
||||
if let Some(region) = params.region {
|
||||
client = client.region(region);
|
||||
}
|
||||
@@ -105,7 +109,38 @@ impl RemoteFsBuilder {
|
||||
if let Some(session_token) = params.session_token {
|
||||
client = client.session_token(session_token);
|
||||
}
|
||||
Ok(client)
|
||||
let client = client.into_blocking(runtime.handle().clone());
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// Build a Google Cloud Storage client from parameters.
|
||||
fn gcs_client(params: GoogleCloudStorageParams) -> Result<RuntimeRemoteFs, String> {
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let mut client = match params.service_account_key {
|
||||
None => GoogleCloudStorageFs::new(params.bucket_name),
|
||||
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),
|
||||
)
|
||||
}
|
||||
};
|
||||
client = client.endpoint(params.endpoint);
|
||||
let client = client.into_blocking(runtime.handle().clone());
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// Build ftp client from parameters
|
||||
@@ -124,34 +159,53 @@ impl RemoteFsBuilder {
|
||||
}
|
||||
|
||||
/// Build kube client
|
||||
fn kube_client(params: KubeProtocolParams) -> Result<KubeFs, String> {
|
||||
let rt = Self::tokio_runtime()?;
|
||||
let kube_fs = KubeFs::new(&rt);
|
||||
if let Some(config) = params.config() {
|
||||
Ok(kube_fs.config(config))
|
||||
fn kube_client(params: KubeProtocolParams) -> Result<RuntimeRemoteFs, String> {
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let kube_fs = if let Some(config) = params.config() {
|
||||
KubeMultiPodFs::new().config(config)
|
||||
} else {
|
||||
Ok(kube_fs)
|
||||
}
|
||||
KubeMultiPodFs::new()
|
||||
};
|
||||
|
||||
let client = kube_fs.into_blocking(runtime.handle().clone());
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// Build scp client
|
||||
fn scp_client(
|
||||
params: GenericProtocolParams,
|
||||
config_client: &ConfigClient,
|
||||
) -> Result<ScpFs<SshSession<NoCheckServerKey>>, String> {
|
||||
) -> Result<RuntimeRemoteFs, String> {
|
||||
let opts = Self::build_ssh_opts(params, config_client);
|
||||
let rt = Self::tokio_runtime()?;
|
||||
Ok(ScpFs::russh(opts, rt))
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let client =
|
||||
RusshScpFs::<NoCheckServerKey>::new(opts).into_blocking(runtime.handle().clone());
|
||||
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// Build sftp client
|
||||
fn sftp_client(
|
||||
params: GenericProtocolParams,
|
||||
config_client: &ConfigClient,
|
||||
) -> Result<SftpFs<SshSession<NoCheckServerKey>>, String> {
|
||||
) -> Result<RuntimeRemoteFs, String> {
|
||||
let opts = Self::build_ssh_opts(params, config_client);
|
||||
let rt = Self::tokio_runtime()?;
|
||||
Ok(SftpFs::russh(opts, rt))
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let client =
|
||||
RusshSftpFs::<NoCheckServerKey>::new(opts).into_blocking(runtime.handle().clone());
|
||||
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// 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)]
|
||||
@@ -170,11 +224,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,11 +250,20 @@ impl RemoteFsBuilder {
|
||||
credentials = credentials.password(password);
|
||||
}
|
||||
|
||||
// Dialect is OS-managed on Windows.
|
||||
Ok(SmbFs::new(credentials))
|
||||
}
|
||||
|
||||
fn webdav_client(params: WebDAVProtocolParams) -> WebDAVFs {
|
||||
WebDAVFs::new(¶ms.username, ¶ms.password, ¶ms.uri)
|
||||
fn webdav_client(params: WebDAVProtocolParams) -> Result<RuntimeRemoteFs, String> {
|
||||
let runtime = Self::tokio_runtime()?;
|
||||
let client = WebDAVFs::new(
|
||||
¶ms.uri,
|
||||
remotefs_webdav::Auth::basic(params.username, params.password),
|
||||
)
|
||||
.map_err(|e| format!("failed to create WebDAV client: {e}"))?;
|
||||
|
||||
let client = client.into_blocking(runtime.handle().clone());
|
||||
Ok(RuntimeRemoteFs::new(client, runtime))
|
||||
}
|
||||
|
||||
/// Build ssh options from generic protocol params and client configuration
|
||||
@@ -206,37 +272,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.
|
||||
@@ -261,14 +299,12 @@ impl RemoteFsBuilder {
|
||||
}
|
||||
|
||||
/// Create tokio runtime to run async code for remotefs
|
||||
fn tokio_runtime() -> Result<Arc<tokio::runtime::Runtime>, String> {
|
||||
Ok(Arc::new(
|
||||
tokio::runtime::Builder::new_current_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Unable to create tokio runtime: {e}"))?,
|
||||
))
|
||||
fn tokio_runtime() -> Result<tokio::runtime::Runtime, String> {
|
||||
tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.map_err(|e| format!("Unable to create tokio runtime: {e}"))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -277,6 +313,8 @@ mod test {
|
||||
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
#[cfg(smb)]
|
||||
use serial_test::serial;
|
||||
use tempfile::TempDir;
|
||||
|
||||
use super::*;
|
||||
@@ -298,6 +336,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 +448,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(
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
//! Runtime-owning wrapper for asynchronous remote filesystem adapters.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::path::Path;
|
||||
|
||||
use remotefs::fs::{
|
||||
Capabilities, ExecOutput, ReadOptions, ReadStream, SetMetadata, UnixPex, WriteOptions,
|
||||
WriteStream,
|
||||
};
|
||||
use remotefs::{File, RemoteFs, RemoteResult};
|
||||
use tokio::runtime::Runtime;
|
||||
|
||||
pub(super) struct RuntimeRemoteFs {
|
||||
// Fields are dropped in declaration order, so the client cannot outlive its runtime.
|
||||
remote: Box<dyn RemoteFs>,
|
||||
_runtime: Runtime,
|
||||
}
|
||||
|
||||
impl RuntimeRemoteFs {
|
||||
pub(super) fn new(remote: impl RemoteFs + 'static, runtime: Runtime) -> Self {
|
||||
Self {
|
||||
remote: Box::new(remote),
|
||||
_runtime: runtime,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteFs for RuntimeRemoteFs {
|
||||
fn connect(&mut self) -> RemoteResult<()> {
|
||||
self.remote.connect()
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) -> RemoteResult<()> {
|
||||
self.remote.disconnect()
|
||||
}
|
||||
|
||||
fn is_connected(&self) -> bool {
|
||||
self.remote.is_connected()
|
||||
}
|
||||
|
||||
fn capabilities(&self) -> Capabilities {
|
||||
self.remote.capabilities()
|
||||
}
|
||||
|
||||
fn list_dir(&self, path: &Path) -> RemoteResult<Vec<File>> {
|
||||
self.remote.list_dir(path)
|
||||
}
|
||||
|
||||
fn stat(&self, path: &Path) -> RemoteResult<File> {
|
||||
self.remote.stat(path)
|
||||
}
|
||||
|
||||
fn exists(&self, path: &Path) -> RemoteResult<bool> {
|
||||
self.remote.exists(path)
|
||||
}
|
||||
|
||||
fn set_metadata(&self, path: &Path, metadata: &SetMetadata) -> RemoteResult<()> {
|
||||
self.remote.set_metadata(path, metadata)
|
||||
}
|
||||
|
||||
fn create_dir(&self, path: &Path, mode: Option<UnixPex>) -> RemoteResult<()> {
|
||||
self.remote.create_dir(path, mode)
|
||||
}
|
||||
|
||||
fn remove_file(&self, path: &Path) -> RemoteResult<()> {
|
||||
self.remote.remove_file(path)
|
||||
}
|
||||
|
||||
fn remove_dir(&self, path: &Path) -> RemoteResult<()> {
|
||||
self.remote.remove_dir(path)
|
||||
}
|
||||
|
||||
fn remove_dir_all(&self, path: &Path) -> RemoteResult<()> {
|
||||
self.remote.remove_dir_all(path)
|
||||
}
|
||||
|
||||
fn rename(&self, src: &Path, dest: &Path) -> RemoteResult<()> {
|
||||
self.remote.rename(src, dest)
|
||||
}
|
||||
|
||||
fn copy(&self, src: &Path, dest: &Path) -> RemoteResult<()> {
|
||||
self.remote.copy(src, dest)
|
||||
}
|
||||
|
||||
fn symlink(&self, path: &Path, target: &Path) -> RemoteResult<()> {
|
||||
self.remote.symlink(path, target)
|
||||
}
|
||||
|
||||
fn open(&self, path: &Path, opts: &ReadOptions) -> RemoteResult<ReadStream> {
|
||||
self.remote.open(path, opts)
|
||||
}
|
||||
|
||||
fn create(&self, path: &Path, opts: &WriteOptions) -> RemoteResult<WriteStream> {
|
||||
self.remote.create(path, opts)
|
||||
}
|
||||
|
||||
fn append(&self, path: &Path, opts: &WriteOptions) -> RemoteResult<WriteStream> {
|
||||
self.remote.append(path, opts)
|
||||
}
|
||||
|
||||
fn read_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
opts: &ReadOptions,
|
||||
dest: &mut (dyn Write + Send),
|
||||
) -> RemoteResult<u64> {
|
||||
self.remote.read_file(path, opts, dest)
|
||||
}
|
||||
|
||||
fn write_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
opts: &WriteOptions,
|
||||
src: &mut (dyn Read + Send),
|
||||
) -> RemoteResult<u64> {
|
||||
self.remote.write_file(path, opts, src)
|
||||
}
|
||||
|
||||
fn append_file(
|
||||
&self,
|
||||
path: &Path,
|
||||
opts: &WriteOptions,
|
||||
src: &mut (dyn Read + Send),
|
||||
) -> RemoteResult<u64> {
|
||||
self.remote.append_file(path, opts, src)
|
||||
}
|
||||
|
||||
fn exec(&self, cmd: &str) -> RemoteResult<ExecOutput> {
|
||||
self.remote.exec(cmd)
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use remotefs_ftp::FtpFs;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn runtime_remote_fs_keeps_runtime_alive() {
|
||||
let runtime = tokio::runtime::Builder::new_multi_thread()
|
||||
.worker_threads(1)
|
||||
.enable_all()
|
||||
.build()
|
||||
.unwrap();
|
||||
let handle = runtime.handle().clone();
|
||||
let remote = RuntimeRemoteFs::new(FtpFs::new("127.0.0.1", 21), runtime);
|
||||
|
||||
let task = handle.spawn(async { 42 });
|
||||
|
||||
assert_eq!(handle.block_on(task).unwrap(), 42);
|
||||
drop(remote);
|
||||
}
|
||||
}
|
||||
+5
-1
@@ -11,7 +11,8 @@ use std::path::{Path, PathBuf};
|
||||
use thiserror::Error;
|
||||
|
||||
// Locals
|
||||
pub use self::bridge::HostBridge;
|
||||
#[doc(inline)]
|
||||
pub use self::bridge::{HostBridge, HostReader, HostWriter};
|
||||
pub use self::localhost::Localhost;
|
||||
pub use self::remote_bridged::RemoteBridged;
|
||||
|
||||
@@ -36,6 +37,8 @@ pub enum HostErrorType {
|
||||
ExecutionFailed,
|
||||
#[error("Could not delete file")]
|
||||
DeleteFailed,
|
||||
#[error("Invalid SSH configuration: {0}")]
|
||||
InvalidSshConfig(String),
|
||||
#[cfg(win)]
|
||||
#[error("Not implemented")]
|
||||
NotImplemented,
|
||||
@@ -46,6 +49,7 @@ pub enum HostErrorType {
|
||||
/// HostError is a wrapper for the error type and the exact io error
|
||||
#[derive(Debug, Error)]
|
||||
pub struct HostError {
|
||||
#[source]
|
||||
pub error: HostErrorType,
|
||||
ioerr: Option<std::io::Error>,
|
||||
path: Option<PathBuf>,
|
||||
|
||||
+213
-8
@@ -3,7 +3,8 @@
|
||||
//! Defines the host abstraction used to expose localhost and bridged remote
|
||||
//! filesystems through a shared interface.
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::fmt;
|
||||
use std::io::{self, Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
|
||||
use remotefs::File;
|
||||
@@ -11,6 +12,111 @@ use remotefs::fs::{Metadata, UnixPex};
|
||||
|
||||
use super::HostResult;
|
||||
|
||||
enum HostReaderInner {
|
||||
Io(Box<dyn Read + Send>),
|
||||
Remote(remotefs::fs::ReadStream),
|
||||
}
|
||||
|
||||
/// An owned host reader that optionally retains a remote transfer finalizer.
|
||||
pub struct HostReader(HostReaderInner);
|
||||
|
||||
impl HostReader {
|
||||
pub(crate) fn io<T>(reader: T) -> Self
|
||||
where
|
||||
T: Read + Send + 'static,
|
||||
{
|
||||
Self(HostReaderInner::Io(Box::new(reader)))
|
||||
}
|
||||
|
||||
pub(crate) fn remote(reader: remotefs::fs::ReadStream) -> Self {
|
||||
Self(HostReaderInner::Remote(reader))
|
||||
}
|
||||
|
||||
/// Completes the remote read and consumes this reader.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the remote stream finalization error, if the reader is backed
|
||||
/// by a remote stream.
|
||||
pub fn finish(self) -> super::HostResult<()> {
|
||||
match self.0 {
|
||||
HostReaderInner::Io(_) => Ok(()),
|
||||
HostReaderInner::Remote(reader) => reader.finish().map_err(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HostReader {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.debug_struct("HostReader").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Read for HostReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> io::Result<usize> {
|
||||
match &mut self.0 {
|
||||
HostReaderInner::Io(reader) => reader.read(buffer),
|
||||
HostReaderInner::Remote(reader) => reader.read(buffer),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
enum HostWriterInner {
|
||||
Io(Box<dyn Write + Send>),
|
||||
Remote(remotefs::fs::WriteStream),
|
||||
}
|
||||
|
||||
/// An owned host writer that optionally retains a remote transfer finalizer.
|
||||
pub struct HostWriter(HostWriterInner);
|
||||
|
||||
impl HostWriter {
|
||||
pub(crate) fn io<T>(writer: T) -> Self
|
||||
where
|
||||
T: Write + Send + 'static,
|
||||
{
|
||||
Self(HostWriterInner::Io(Box::new(writer)))
|
||||
}
|
||||
|
||||
pub(crate) fn remote(writer: remotefs::fs::WriteStream) -> Self {
|
||||
Self(HostWriterInner::Remote(writer))
|
||||
}
|
||||
|
||||
/// Completes the remote write and consumes this writer.
|
||||
///
|
||||
/// # Errors
|
||||
///
|
||||
/// Returns the remote stream finalization error, if the writer is backed
|
||||
/// by a remote stream.
|
||||
pub fn finish(self) -> super::HostResult<()> {
|
||||
match self.0 {
|
||||
HostWriterInner::Io(_) => Ok(()),
|
||||
HostWriterInner::Remote(writer) => writer.finish().map_err(Into::into),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl fmt::Debug for HostWriter {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
formatter.debug_struct("HostWriter").finish_non_exhaustive()
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for HostWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> io::Result<usize> {
|
||||
match &mut self.0 {
|
||||
HostWriterInner::Io(writer) => writer.write(buffer),
|
||||
HostWriterInner::Remote(writer) => writer.write(buffer),
|
||||
}
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
match &mut self.0 {
|
||||
HostWriterInner::Io(writer) => writer.flush(),
|
||||
HostWriterInner::Remote(writer) => writer.flush(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Trait to bridge a remote filesystem to the host filesystem
|
||||
///
|
||||
/// In case of `Localhost` this should be effortless, while for remote hosts this should
|
||||
@@ -75,15 +181,114 @@ pub trait HostBridge {
|
||||
fn chmod(&mut self, path: &Path, pex: UnixPex) -> HostResult<()>;
|
||||
|
||||
/// Open file for reading
|
||||
fn open_file(&mut self, file: &Path) -> HostResult<Box<dyn Read + Send>>;
|
||||
fn open_file(&mut self, file: &Path) -> HostResult<HostReader>;
|
||||
|
||||
/// Open file for writing
|
||||
fn create_file(
|
||||
&mut self,
|
||||
file: &Path,
|
||||
metadata: &Metadata,
|
||||
) -> HostResult<Box<dyn Write + Send>>;
|
||||
fn create_file(&mut self, file: &Path, metadata: &Metadata) -> HostResult<HostWriter>;
|
||||
|
||||
/// Finalize write operation
|
||||
fn finalize_write(&mut self, writer: Box<dyn Write + Send>) -> HostResult<()>;
|
||||
fn finalize_write(&mut self, writer: HostWriter) -> HostResult<()>;
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::io::Cursor;
|
||||
use std::sync::Arc;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
use remotefs::fs::{ReadStream, RemoteRead, RemoteWrite, WriteStream};
|
||||
|
||||
use super::*;
|
||||
|
||||
struct TrackedReader {
|
||||
reader: Cursor<Vec<u8>>,
|
||||
finishes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Read for TrackedReader {
|
||||
fn read(&mut self, buffer: &mut [u8]) -> std::io::Result<usize> {
|
||||
self.reader.read(buffer)
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteRead for TrackedReader {
|
||||
fn finish(self: Box<Self>) -> remotefs::RemoteResult<()> {
|
||||
self.finishes.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
struct TrackedWriter {
|
||||
writer: Cursor<Vec<u8>>,
|
||||
finishes: Arc<AtomicUsize>,
|
||||
}
|
||||
|
||||
impl Write for TrackedWriter {
|
||||
fn write(&mut self, buffer: &[u8]) -> std::io::Result<usize> {
|
||||
self.writer.write(buffer)
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
self.writer.flush()
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteWrite for TrackedWriter {
|
||||
fn finish(self: Box<Self>) -> remotefs::RemoteResult<()> {
|
||||
self.finishes.fetch_add(1, Ordering::SeqCst);
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_reader_finishes_exactly_once_when_consumed() {
|
||||
let finishes = Arc::new(AtomicUsize::new(0));
|
||||
let reader = HostReader::remote(ReadStream::new(TrackedReader {
|
||||
reader: Cursor::new(Vec::new()),
|
||||
finishes: finishes.clone(),
|
||||
}));
|
||||
|
||||
reader.finish().unwrap();
|
||||
|
||||
assert_eq!(finishes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn remote_writer_finishes_exactly_once_when_consumed() {
|
||||
let finishes = Arc::new(AtomicUsize::new(0));
|
||||
let writer = HostWriter::remote(WriteStream::new(TrackedWriter {
|
||||
writer: Cursor::new(Vec::new()),
|
||||
finishes: finishes.clone(),
|
||||
}));
|
||||
|
||||
writer.finish().unwrap();
|
||||
|
||||
assert_eq!(finishes.load(Ordering::SeqCst), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_remote_reader_is_dropped_without_finalizing() {
|
||||
let finishes = Arc::new(AtomicUsize::new(0));
|
||||
let reader = HostReader::remote(ReadStream::new(TrackedReader {
|
||||
reader: Cursor::new(Vec::new()),
|
||||
finishes: finishes.clone(),
|
||||
}));
|
||||
|
||||
drop(reader);
|
||||
|
||||
assert_eq!(finishes.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unfinished_remote_writer_is_dropped_without_finalizing() {
|
||||
let finishes = Arc::new(AtomicUsize::new(0));
|
||||
let writer = HostWriter::remote(WriteStream::new(TrackedWriter {
|
||||
writer: Cursor::new(Vec::new()),
|
||||
finishes: finishes.clone(),
|
||||
}));
|
||||
|
||||
drop(writer);
|
||||
|
||||
assert_eq!(finishes.load(Ordering::SeqCst), 0);
|
||||
}
|
||||
}
|
||||
|
||||
+12
-14
@@ -4,7 +4,7 @@
|
||||
//! filesystem.
|
||||
|
||||
use std::fs::{self, OpenOptions};
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
#[cfg(posix)]
|
||||
use std::os::unix::fs::PermissionsExt as _;
|
||||
use std::path::{Path, PathBuf};
|
||||
@@ -13,7 +13,7 @@ use filetime::FileTime;
|
||||
use remotefs::File;
|
||||
use remotefs::fs::{FileType, Metadata, UnixPex};
|
||||
|
||||
use super::{HostBridge, HostResult};
|
||||
use super::{HostBridge, HostReader, HostResult, HostWriter};
|
||||
use crate::host::{HostError, HostErrorType};
|
||||
use crate::utils::path;
|
||||
|
||||
@@ -368,7 +368,7 @@ impl HostBridge for Localhost {
|
||||
metadata.file_type = FileType::Symlink;
|
||||
}
|
||||
// Match dir / file
|
||||
Ok(File { path, metadata })
|
||||
Ok(File::new(path, metadata))
|
||||
}
|
||||
|
||||
fn exists(&mut self, path: &Path) -> HostResult<bool> {
|
||||
@@ -520,7 +520,7 @@ impl HostBridge for Localhost {
|
||||
Err(HostError::from(HostErrorType::NotImplemented))
|
||||
}
|
||||
|
||||
fn open_file(&mut self, file: &std::path::Path) -> HostResult<Box<dyn Read + Send>> {
|
||||
fn open_file(&mut self, file: &std::path::Path) -> HostResult<HostReader> {
|
||||
let file: PathBuf = self.to_path(file);
|
||||
info!("Opening file {} for read", file.display());
|
||||
if !self.exists(file.as_path())? {
|
||||
@@ -537,7 +537,7 @@ impl HostBridge for Localhost {
|
||||
.write(false)
|
||||
.open(file.as_path())
|
||||
{
|
||||
Ok(f) => Ok(Box::new(f)),
|
||||
Ok(f) => Ok(HostReader::io(f)),
|
||||
Err(err) => {
|
||||
error!("Could not open file for read: {}", err);
|
||||
Err(HostError::new(
|
||||
@@ -549,11 +549,7 @@ impl HostBridge for Localhost {
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(
|
||||
&mut self,
|
||||
file: &Path,
|
||||
_metadata: &Metadata,
|
||||
) -> HostResult<Box<dyn Write + Send>> {
|
||||
fn create_file(&mut self, file: &Path, _metadata: &Metadata) -> HostResult<HostWriter> {
|
||||
let file: PathBuf = self.to_path(file);
|
||||
info!("Opening file {} for write", file.display());
|
||||
match OpenOptions::new()
|
||||
@@ -562,7 +558,7 @@ impl HostBridge for Localhost {
|
||||
.truncate(true)
|
||||
.open(file.as_path())
|
||||
{
|
||||
Ok(f) => Ok(Box::new(f)),
|
||||
Ok(f) => Ok(HostWriter::io(f)),
|
||||
Err(err) => {
|
||||
error!("Failed to open file: {}", err);
|
||||
match self.exists(file.as_path())? {
|
||||
@@ -581,9 +577,11 @@ impl HostBridge for Localhost {
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_write(&mut self, _writer: Box<dyn Write + Send>) -> HostResult<()> {
|
||||
// no-op
|
||||
Ok(())
|
||||
fn finalize_write(&mut self, mut writer: HostWriter) -> HostResult<()> {
|
||||
writer.flush().map_err(|error| {
|
||||
HostError::new(HostErrorType::FileNotAccessible, Some(error), Path::new(""))
|
||||
})?;
|
||||
writer.finish()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+238
-89
@@ -5,19 +5,19 @@
|
||||
|
||||
mod temp_mapped_file;
|
||||
|
||||
use std::io::{Read, Write};
|
||||
use std::io::Write;
|
||||
use std::path::{Component, Path, PathBuf};
|
||||
|
||||
use remotefs::fs::{Metadata, UnixPex};
|
||||
use remotefs::fs::{Capabilities, Metadata, ReadOptions, SetMetadata, UnixPex, WriteOptions};
|
||||
use remotefs::{File, RemoteError, RemoteErrorType, RemoteFs};
|
||||
|
||||
use self::temp_mapped_file::TempMappedFile;
|
||||
use super::{HostBridge, HostError, HostResult};
|
||||
use super::{HostBridge, HostError, HostErrorType, HostReader, HostResult, HostWriter};
|
||||
use crate::utils::path::normalize;
|
||||
|
||||
struct WriteStreamOp {
|
||||
path: PathBuf,
|
||||
metadata: Metadata,
|
||||
options: WriteOptions,
|
||||
tempfile: TempMappedFile,
|
||||
}
|
||||
|
||||
@@ -25,30 +25,81 @@ struct WriteStreamOp {
|
||||
pub struct RemoteBridged {
|
||||
/// Remote fs client
|
||||
remote: Box<dyn RemoteFs>,
|
||||
/// Consumer-owned remote working directory used to resolve relative paths
|
||||
wrkdir: PathBuf,
|
||||
/// Reminder used to finalize write stream
|
||||
write_stream_op: Option<WriteStreamOp>,
|
||||
}
|
||||
|
||||
impl RemoteBridged {
|
||||
fn open_file_from_temp(&mut self, file: &Path) -> HostResult<Box<dyn Read + Send>> {
|
||||
fn open_file_from_temp(&mut self, file: &Path) -> HostResult<HostReader> {
|
||||
let mut temp_file = TempMappedFile::new()?;
|
||||
|
||||
self.remote
|
||||
.open_file(file, Box::new(temp_file.clone()))
|
||||
.read_file(file, &ReadOptions::default(), &mut temp_file)
|
||||
.map_err(HostError::from)?;
|
||||
|
||||
// Sync changes
|
||||
temp_file.sync()?;
|
||||
|
||||
// now return as read
|
||||
Ok(Box::new(temp_file))
|
||||
Ok(HostReader::io(temp_file))
|
||||
}
|
||||
}
|
||||
|
||||
fn resolve_remote_path(wrkdir: &Path, target: &Path) -> HostResult<PathBuf> {
|
||||
let resolved = if remotefs::path::ensure_absolute(target).is_ok() {
|
||||
target.to_path_buf()
|
||||
} else {
|
||||
wrkdir.join(target)
|
||||
};
|
||||
remotefs::path::ensure_absolute(&resolved).map_err(HostError::from)?;
|
||||
Ok(resolved)
|
||||
}
|
||||
|
||||
fn write_options(metadata: &Metadata) -> WriteOptions {
|
||||
let mut options = WriteOptions::default();
|
||||
if let Some(size) = metadata.size {
|
||||
options = options.size_hint(size);
|
||||
}
|
||||
if let Some(mode) = metadata.mode {
|
||||
options = options.mode(mode);
|
||||
}
|
||||
if let Some(modified) = metadata.modified {
|
||||
options = options.modified(modified);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn set_metadata_options(metadata: &Metadata) -> SetMetadata {
|
||||
let mut options = SetMetadata::default();
|
||||
if let Some(mode) = metadata.mode {
|
||||
options = options.mode(mode);
|
||||
}
|
||||
if let Some(uid) = metadata.uid {
|
||||
options = options.uid(uid);
|
||||
}
|
||||
if let Some(gid) = metadata.gid {
|
||||
options = options.gid(gid);
|
||||
}
|
||||
if let Some(accessed) = metadata.accessed {
|
||||
options = options.accessed(accessed);
|
||||
}
|
||||
if let Some(modified) = metadata.modified {
|
||||
options = options.modified(modified);
|
||||
}
|
||||
options
|
||||
}
|
||||
|
||||
fn map_io_error(error: std::io::Error) -> HostError {
|
||||
HostError::new(HostErrorType::FileNotAccessible, Some(error), Path::new(""))
|
||||
}
|
||||
|
||||
impl From<Box<dyn RemoteFs>> for RemoteBridged {
|
||||
fn from(remote: Box<dyn RemoteFs>) -> Self {
|
||||
RemoteBridged {
|
||||
remote,
|
||||
wrkdir: PathBuf::from("/"),
|
||||
write_stream_op: None,
|
||||
}
|
||||
}
|
||||
@@ -56,7 +107,7 @@ impl From<Box<dyn RemoteFs>> for RemoteBridged {
|
||||
|
||||
impl HostBridge for RemoteBridged {
|
||||
fn connect(&mut self) -> HostResult<()> {
|
||||
self.remote.connect().map(|_| ()).map_err(HostError::from)
|
||||
self.remote.connect().map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn disconnect(&mut self) -> HostResult<()> {
|
||||
@@ -73,71 +124,85 @@ impl HostBridge for RemoteBridged {
|
||||
|
||||
fn pwd(&mut self) -> HostResult<PathBuf> {
|
||||
debug!("Getting working directory");
|
||||
self.remote.pwd().map_err(HostError::from)
|
||||
Ok(self.wrkdir.clone())
|
||||
}
|
||||
|
||||
fn change_wrkdir(&mut self, new_dir: &Path) -> HostResult<PathBuf> {
|
||||
debug!("Changing working directory to {:?}", new_dir);
|
||||
self.remote.change_dir(new_dir).map_err(HostError::from)
|
||||
let new_dir = resolve_remote_path(&self.wrkdir, new_dir)?;
|
||||
let entry = self.remote.stat(&new_dir).map_err(HostError::from)?;
|
||||
if !entry.is_dir() {
|
||||
return Err(HostError::from(RemoteError::new(RemoteErrorType::BadFile)));
|
||||
}
|
||||
self.wrkdir = new_dir;
|
||||
Ok(self.wrkdir.clone())
|
||||
}
|
||||
|
||||
fn mkdir_ex(&mut self, dir_name: &Path, ignore_existing: bool) -> HostResult<()> {
|
||||
debug!("Creating directory {:?}", dir_name);
|
||||
match self.remote.create_dir(dir_name, UnixPex::from(0o755)) {
|
||||
Ok(_) => Ok(()),
|
||||
Err(remotefs::RemoteError {
|
||||
kind: RemoteErrorType::DirectoryAlreadyExists,
|
||||
..
|
||||
}) if ignore_existing => Ok(()),
|
||||
Err(e) => Err(HostError::from(e)),
|
||||
let path = resolve_remote_path(&self.wrkdir, dir_name)?;
|
||||
match self.remote.create_dir(&path, Some(UnixPex::from(0o755))) {
|
||||
Ok(()) => Ok(()),
|
||||
Err(error) if ignore_existing && error.kind() == RemoteErrorType::AlreadyExists => {
|
||||
Ok(())
|
||||
}
|
||||
Err(error) => Err(HostError::from(error)),
|
||||
}
|
||||
}
|
||||
|
||||
fn remove(&mut self, entry: &File) -> HostResult<()> {
|
||||
debug!("Removing {:?}", entry.path());
|
||||
let path = resolve_remote_path(&self.wrkdir, entry.path())?;
|
||||
if entry.is_dir() {
|
||||
self.remote
|
||||
.remove_dir_all(entry.path())
|
||||
.map_err(HostError::from)
|
||||
self.remote.remove_dir_all(&path).map_err(HostError::from)
|
||||
} else {
|
||||
self.remote
|
||||
.remove_file(entry.path())
|
||||
.map_err(HostError::from)
|
||||
self.remote.remove_file(&path).map_err(HostError::from)
|
||||
}
|
||||
}
|
||||
|
||||
fn rename(&mut self, entry: &File, dst_path: &Path) -> HostResult<()> {
|
||||
debug!("Renaming {:?} to {:?}", entry.path(), dst_path);
|
||||
let source = resolve_remote_path(&self.wrkdir, entry.path())?;
|
||||
let destination = resolve_remote_path(&self.wrkdir, dst_path)?;
|
||||
self.remote
|
||||
.mov(entry.path(), dst_path)
|
||||
.rename(&source, &destination)
|
||||
.map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn copy(&mut self, entry: &File, dst: &Path) -> HostResult<()> {
|
||||
debug!("Copying {:?} to {:?}", entry.path(), dst);
|
||||
self.remote.copy(entry.path(), dst).map_err(HostError::from)
|
||||
let source = resolve_remote_path(&self.wrkdir, entry.path())?;
|
||||
let destination = resolve_remote_path(&self.wrkdir, dst)?;
|
||||
self.remote
|
||||
.copy(&source, &destination)
|
||||
.map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn stat(&mut self, path: &Path) -> HostResult<File> {
|
||||
debug!("Statting {:?}", path);
|
||||
self.remote.stat(path).map_err(HostError::from)
|
||||
let path = resolve_remote_path(&self.wrkdir, path)?;
|
||||
self.remote.stat(&path).map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn exists(&mut self, path: &Path) -> HostResult<bool> {
|
||||
debug!("Checking existence of {:?}", path);
|
||||
self.remote.exists(path).map_err(HostError::from)
|
||||
let path = resolve_remote_path(&self.wrkdir, path)?;
|
||||
self.remote.exists(&path).map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn list_dir(&mut self, path: &Path) -> HostResult<Vec<File>> {
|
||||
debug!("Listing directory {:?}", path);
|
||||
let entries = self.remote.list_dir(path).map_err(HostError::from)?;
|
||||
Ok(filter_self_refs(path, entries))
|
||||
let path = resolve_remote_path(&self.wrkdir, path)?;
|
||||
let entries = self.remote.list_dir(&path).map_err(HostError::from)?;
|
||||
Ok(filter_self_refs(&path, entries))
|
||||
}
|
||||
|
||||
fn setstat(&mut self, path: &Path, metadata: &Metadata) -> HostResult<()> {
|
||||
debug!("Setting metadata for {:?}", path);
|
||||
let path = resolve_remote_path(&self.wrkdir, path)?;
|
||||
let options = set_metadata_options(metadata);
|
||||
self.remote
|
||||
.setstat(path, metadata.clone())
|
||||
.set_metadata(&path, &options)
|
||||
.map_err(HostError::from)
|
||||
}
|
||||
|
||||
@@ -145,78 +210,108 @@ impl HostBridge for RemoteBridged {
|
||||
debug!("Executing command: {}", cmd);
|
||||
self.remote
|
||||
.exec(cmd)
|
||||
.map(|(_, stdout)| stdout)
|
||||
.map(|output| output.stdout)
|
||||
.map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn symlink(&mut self, src: &Path, dst: &Path) -> HostResult<()> {
|
||||
debug!("Creating symlink from {:?} to {:?}", src, dst);
|
||||
self.remote.symlink(src, dst).map_err(HostError::from)
|
||||
let path = resolve_remote_path(&self.wrkdir, src)?;
|
||||
let target = resolve_remote_path(&self.wrkdir, dst)?;
|
||||
self.remote.symlink(&path, &target).map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn chmod(&mut self, path: &Path, pex: UnixPex) -> HostResult<()> {
|
||||
debug!("Changing permissions of {:?} to {:?}", path, pex);
|
||||
let stat = self.remote.stat(path).map_err(HostError::from)?;
|
||||
let mut metadata = stat.metadata.clone();
|
||||
metadata.mode = Some(pex);
|
||||
|
||||
self.setstat(path, &metadata)
|
||||
let path = resolve_remote_path(&self.wrkdir, path)?;
|
||||
self.remote
|
||||
.set_metadata(&path, &SetMetadata::default().mode(pex))
|
||||
.map_err(HostError::from)
|
||||
}
|
||||
|
||||
fn open_file(&mut self, file: &Path) -> HostResult<Box<dyn Read + Send>> {
|
||||
// try to use stream, otherwise download to a temporary file and return a reader
|
||||
match self.remote.open(file) {
|
||||
Ok(stream) => Ok(Box::new(stream)),
|
||||
Err(RemoteError {
|
||||
kind: RemoteErrorType::UnsupportedFeature,
|
||||
..
|
||||
}) => self.open_file_from_temp(file),
|
||||
Err(e) => Err(HostError::from(e)),
|
||||
}
|
||||
}
|
||||
|
||||
fn create_file(
|
||||
&mut self,
|
||||
file: &Path,
|
||||
metadata: &Metadata,
|
||||
) -> HostResult<Box<dyn Write + Send>> {
|
||||
// try to use stream, otherwise download to a temporary file and return a reader
|
||||
match self.remote.create(file, metadata) {
|
||||
Ok(stream) => Ok(Box::new(stream)),
|
||||
Err(RemoteError {
|
||||
kind: RemoteErrorType::UnsupportedFeature,
|
||||
..
|
||||
}) => {
|
||||
let tempfile = TempMappedFile::new()?;
|
||||
self.write_stream_op = Some(WriteStreamOp {
|
||||
path: file.to_path_buf(),
|
||||
metadata: metadata.clone(),
|
||||
tempfile: tempfile.clone(),
|
||||
});
|
||||
|
||||
Ok(Box::new(tempfile))
|
||||
fn open_file(&mut self, file: &Path) -> HostResult<HostReader> {
|
||||
let path = resolve_remote_path(&self.wrkdir, file)?;
|
||||
if self
|
||||
.remote
|
||||
.capabilities()
|
||||
.contains(Capabilities::STREAM_READ)
|
||||
{
|
||||
match self.remote.open(&path, &ReadOptions::default()) {
|
||||
Ok(stream) => Ok(HostReader::remote(stream)),
|
||||
Err(error) if error.kind() == RemoteErrorType::UnsupportedFeature => {
|
||||
self.open_file_from_temp(&path)
|
||||
}
|
||||
Err(error) => Err(HostError::from(error)),
|
||||
}
|
||||
Err(e) => Err(HostError::from(e)),
|
||||
} else {
|
||||
self.open_file_from_temp(&path)
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_write(&mut self, _writer: Box<dyn Write + Send>) -> HostResult<()> {
|
||||
fn create_file(&mut self, file: &Path, metadata: &Metadata) -> HostResult<HostWriter> {
|
||||
let path = resolve_remote_path(&self.wrkdir, file)?;
|
||||
let options = write_options(metadata);
|
||||
self.write_stream_op = None;
|
||||
if self
|
||||
.remote
|
||||
.capabilities()
|
||||
.contains(Capabilities::STREAM_WRITE)
|
||||
{
|
||||
match self.remote.create(&path, &options) {
|
||||
Ok(stream) => Ok(HostWriter::remote(stream)),
|
||||
Err(error)
|
||||
if matches!(
|
||||
error.kind(),
|
||||
RemoteErrorType::SizeRequired | RemoteErrorType::UnsupportedFeature
|
||||
) =>
|
||||
{
|
||||
self.create_file_from_temp(path, options)
|
||||
}
|
||||
Err(error) => Err(HostError::from(error)),
|
||||
}
|
||||
} else {
|
||||
self.create_file_from_temp(path, options)
|
||||
}
|
||||
}
|
||||
|
||||
fn finalize_write(&mut self, mut writer: HostWriter) -> HostResult<()> {
|
||||
writer.flush().map_err(map_io_error)?;
|
||||
writer.finish()?;
|
||||
if let Some(WriteStreamOp {
|
||||
path,
|
||||
metadata,
|
||||
mut options,
|
||||
mut tempfile,
|
||||
}) = self.write_stream_op.take()
|
||||
{
|
||||
// sync
|
||||
tempfile.sync()?;
|
||||
options = options.size_hint(tempfile.len()?);
|
||||
// write file
|
||||
self.remote
|
||||
.create_file(&path, &metadata, Box::new(tempfile))?;
|
||||
.write_file(&path, &options, &mut tempfile)
|
||||
.map_err(HostError::from)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RemoteBridged {
|
||||
fn create_file_from_temp(
|
||||
&mut self,
|
||||
path: PathBuf,
|
||||
options: WriteOptions,
|
||||
) -> HostResult<HostWriter> {
|
||||
let tempfile = TempMappedFile::new()?;
|
||||
self.write_stream_op = Some(WriteStreamOp {
|
||||
path,
|
||||
options,
|
||||
tempfile: tempfile.clone(),
|
||||
});
|
||||
|
||||
Ok(HostWriter::io(tempfile))
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop entries that refer to the directory being listed.
|
||||
///
|
||||
/// Some non-compliant FTP servers (e.g. LiteSpeed) include a self-reference
|
||||
@@ -236,31 +331,85 @@ fn filter_self_refs(path: &Path, entries: Vec<File>) -> Vec<File> {
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
use std::path::Path;
|
||||
use std::time::{SystemTime, UNIX_EPOCH};
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use remotefs::fs::{FileType, Metadata};
|
||||
use remotefs::fs::{FileType, Metadata, UnixPex};
|
||||
|
||||
use super::*;
|
||||
|
||||
fn file(path: &str, file_type: FileType) -> File {
|
||||
File {
|
||||
path: PathBuf::from(path),
|
||||
metadata: Metadata {
|
||||
accessed: Some(SystemTime::UNIX_EPOCH),
|
||||
created: Some(SystemTime::UNIX_EPOCH),
|
||||
modified: Some(SystemTime::UNIX_EPOCH),
|
||||
file_type,
|
||||
gid: None,
|
||||
mode: None,
|
||||
size: 0,
|
||||
symlink: None,
|
||||
uid: None,
|
||||
},
|
||||
File::new(
|
||||
path,
|
||||
Metadata::default()
|
||||
.accessed(UNIX_EPOCH)
|
||||
.created(UNIX_EPOCH)
|
||||
.modified(UNIX_EPOCH)
|
||||
.file_type(file_type)
|
||||
.size(0),
|
||||
)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resolves_relative_paths_from_consumer_working_directory() {
|
||||
assert_eq!(
|
||||
resolve_remote_path(Path::new("/home/user"), Path::new("docs/report.txt")).unwrap(),
|
||||
Path::new("/home/user/docs/report.txt")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn preserves_absolute_remote_paths() {
|
||||
for path in ["/srv/data", r"C:\data\file", r"\\server\share\file"] {
|
||||
assert_eq!(
|
||||
resolve_remote_path(Path::new("/ignored"), Path::new(path)).unwrap(),
|
||||
Path::new(path)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_metadata_to_write_options() {
|
||||
let modified = SystemTime::UNIX_EPOCH;
|
||||
let mode = UnixPex::from(0o640);
|
||||
let options = write_options(&Metadata::default().size(42).mode(mode).modified(modified));
|
||||
|
||||
assert_eq!(options.size_hint, Some(42));
|
||||
assert_eq!(options.mode, Some(mode));
|
||||
assert_eq!(options.modified, Some(modified));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn maps_settable_metadata_fields() {
|
||||
let accessed = SystemTime::UNIX_EPOCH;
|
||||
let modified = accessed + std::time::Duration::from_secs(1);
|
||||
let mode = UnixPex::from(0o640);
|
||||
let options = set_metadata_options(
|
||||
&Metadata::default()
|
||||
.accessed(accessed)
|
||||
.gid(20)
|
||||
.mode(mode)
|
||||
.modified(modified)
|
||||
.uid(10),
|
||||
);
|
||||
|
||||
assert_eq!(options.accessed, Some(accessed));
|
||||
assert_eq!(options.gid, Some(20));
|
||||
assert_eq!(options.mode, Some(mode));
|
||||
assert_eq!(options.modified, Some(modified));
|
||||
assert_eq!(options.uid, Some(10));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn default_metadata_maps_to_empty_options() {
|
||||
assert_eq!(write_options(&Metadata::default()), Default::default());
|
||||
assert_eq!(
|
||||
set_metadata_options(&Metadata::default()),
|
||||
Default::default()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn filter_self_refs_drops_entry_matching_listed_dir() {
|
||||
let entries = vec![
|
||||
|
||||
@@ -82,6 +82,19 @@ impl TempMappedFile {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Returns the number of bytes currently stored in the temporary file.
|
||||
pub fn len(&self) -> HostResult<u64> {
|
||||
std::fs::metadata(self.tempfile.path())
|
||||
.map(|metadata| metadata.len())
|
||||
.map_err(|error| {
|
||||
HostError::new(
|
||||
HostErrorType::FileNotAccessible,
|
||||
Some(error),
|
||||
self.tempfile.path(),
|
||||
)
|
||||
})
|
||||
}
|
||||
|
||||
fn write_hnd(&mut self) -> io::Result<FileHandle<'_>> {
|
||||
let mut lock = self.lock_handle()?;
|
||||
if lock.is_none() {
|
||||
|
||||
@@ -50,6 +50,20 @@ impl Update {
|
||||
self
|
||||
}
|
||||
|
||||
/// Maps a build target triple onto the target triple used to name the
|
||||
/// official release assets.
|
||||
///
|
||||
/// Official Linux binaries are statically linked against musl, so a
|
||||
/// termscp built against glibc (for example via `cargo install`) must
|
||||
/// still download the `-musl` asset.
|
||||
fn map_release_target(target: &str) -> String {
|
||||
if target.contains("-linux-") {
|
||||
target.replace("-gnu", "-musl")
|
||||
} else {
|
||||
target.to_string()
|
||||
}
|
||||
}
|
||||
|
||||
/// Installs the latest available release using the configured update options.
|
||||
pub fn upgrade(self) -> Result<UpdateStatus, UpdateError> {
|
||||
info!("Updating termscp...");
|
||||
@@ -58,6 +72,7 @@ impl Update {
|
||||
.repo_owner("veeso")
|
||||
.repo_name("termscp")
|
||||
.bin_name("termscp")
|
||||
.target(&Self::map_release_target(self_update::get_target()))
|
||||
.current_version(cargo_crate_version!())
|
||||
.no_confirm(!self.ask_confirm)
|
||||
.show_download_progress(self.progress)
|
||||
@@ -180,6 +195,38 @@ mod test {
|
||||
assert_eq!(upd.progress, true);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_map_linux_release_target_to_musl() {
|
||||
assert_eq!(
|
||||
Update::map_release_target("x86_64-unknown-linux-gnu"),
|
||||
"x86_64-unknown-linux-musl".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
Update::map_release_target("aarch64-unknown-linux-gnu"),
|
||||
"aarch64-unknown-linux-musl".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_leave_non_gnu_linux_release_target_unchanged() {
|
||||
assert_eq!(
|
||||
Update::map_release_target("x86_64-unknown-linux-musl"),
|
||||
"x86_64-unknown-linux-musl".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_leave_other_platform_release_targets_unchanged() {
|
||||
assert_eq!(
|
||||
Update::map_release_target("aarch64-apple-darwin"),
|
||||
"aarch64-apple-darwin".to_string()
|
||||
);
|
||||
assert_eq!(
|
||||
Update::map_release_target("x86_64-pc-windows-msvc"),
|
||||
"x86_64-pc-windows-msvc".to_string()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[cfg(all(
|
||||
not(all(
|
||||
|
||||
@@ -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() {
|
||||
@@ -932,6 +986,16 @@ 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>) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
+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),
|
||||
],
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
//! `filetransfer_activity` is the module which implements the Filetransfer activity, which is the main activity afterall
|
||||
|
||||
use std::fs::OpenOptions;
|
||||
use std::io::Read;
|
||||
use std::io::{Read, Write};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::time::SystemTime;
|
||||
|
||||
@@ -107,10 +107,12 @@ impl FileTransferActivity {
|
||||
}
|
||||
};
|
||||
|
||||
let new_file_size = match std::io::copy(&mut reader, &mut writer) {
|
||||
Err(err) => return Err(format!("Could not write file: {err}")),
|
||||
Ok(size) => size,
|
||||
};
|
||||
if let Err(err) = std::io::copy(&mut reader, &mut writer) {
|
||||
return Err(format!("Could not write file: {err}"));
|
||||
}
|
||||
reader
|
||||
.finish()
|
||||
.map_err(|err| format!("Could not finish file read: {err}"))?;
|
||||
|
||||
// edit file
|
||||
|
||||
@@ -126,10 +128,11 @@ impl FileTransferActivity {
|
||||
};
|
||||
let mut writer = match self.browser.local_pane_mut().fs.create_file(
|
||||
entry.path(),
|
||||
&Metadata {
|
||||
size: new_file_size,
|
||||
..Default::default()
|
||||
},
|
||||
&Metadata::default().size(
|
||||
std::fs::metadata(tempfile.as_path())
|
||||
.map_err(|err| format!("Could not stat edited file: {err}"))?
|
||||
.len(),
|
||||
),
|
||||
) {
|
||||
Ok(writer) => writer,
|
||||
Err(err) => {
|
||||
@@ -141,6 +144,10 @@ impl FileTransferActivity {
|
||||
return Err(format!("Could not write file: {err}"));
|
||||
}
|
||||
|
||||
writer
|
||||
.flush()
|
||||
.map_err(|err| format!("Could not write file: {err}"))?;
|
||||
|
||||
self.browser
|
||||
.local_pane_mut()
|
||||
.fs
|
||||
|
||||
@@ -2,6 +2,12 @@ use remotefs::File;
|
||||
|
||||
use super::{FileTransferActivity, LogLevel};
|
||||
|
||||
fn aggregate_sizes(sizes: impl IntoIterator<Item = Option<u64>>) -> Option<u64> {
|
||||
sizes
|
||||
.into_iter()
|
||||
.try_fold(0_u64, |total, size| total.checked_add(size?))
|
||||
}
|
||||
|
||||
impl FileTransferActivity {
|
||||
/// Calculate and display the total size of the selected file(s) via the active tab's pane.
|
||||
pub(crate) fn action_get_file_size(&mut self) {
|
||||
@@ -11,17 +17,23 @@ impl FileTransferActivity {
|
||||
let total_size = self.get_files_size(files);
|
||||
|
||||
self.umount_wait();
|
||||
self.mount_info(format!(
|
||||
"Total file size: {size}",
|
||||
size = bytesize::ByteSize::b(total_size)
|
||||
));
|
||||
let message = total_size.map_or_else(
|
||||
|| String::from("Total file size: Unknown"),
|
||||
|size| {
|
||||
format!(
|
||||
"Total file size: {size}",
|
||||
size = bytesize::ByteSize::b(size)
|
||||
)
|
||||
},
|
||||
);
|
||||
self.mount_info(message);
|
||||
}
|
||||
|
||||
fn get_files_size(&mut self, files: Vec<File>) -> u64 {
|
||||
files.into_iter().map(|f| self.get_file_size(f)).sum()
|
||||
fn get_files_size(&mut self, files: Vec<File>) -> Option<u64> {
|
||||
aggregate_sizes(files.into_iter().map(|file| self.get_file_size(file)))
|
||||
}
|
||||
|
||||
fn get_file_size(&mut self, file: File) -> u64 {
|
||||
fn get_file_size(&mut self, file: File) -> Option<u64> {
|
||||
if let Some(symlink) = &file.metadata().symlink {
|
||||
match self.browser.fs_pane_mut().fs.stat(symlink) {
|
||||
Ok(stat) => stat.metadata().size,
|
||||
@@ -33,12 +45,12 @@ impl FileTransferActivity {
|
||||
path = symlink.display(),
|
||||
),
|
||||
);
|
||||
0
|
||||
None
|
||||
}
|
||||
}
|
||||
} else if file.is_dir() {
|
||||
match self.browser.fs_pane_mut().fs.list_dir(&file.path) {
|
||||
Ok(list) => list.into_iter().map(|f| self.get_file_size(f)).sum(),
|
||||
Ok(list) => self.get_files_size(list),
|
||||
Err(err) => {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
@@ -47,7 +59,7 @@ impl FileTransferActivity {
|
||||
path = file.path.display(),
|
||||
),
|
||||
);
|
||||
0
|
||||
None
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -55,3 +67,14 @@ impl FileTransferActivity {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::aggregate_sizes;
|
||||
|
||||
#[test]
|
||||
fn aggregates_only_known_sizes() {
|
||||
assert_eq!(aggregate_sizes([Some(2), Some(3)]), Some(5));
|
||||
assert_eq!(aggregate_sizes([Some(2), None, Some(3)]), None);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@ impl FileTransferActivity {
|
||||
.browser
|
||||
.fs_pane_mut()
|
||||
.fs
|
||||
.create_file(file_path.as_path(), &Metadata::default())
|
||||
.create_file(file_path.as_path(), &Metadata::default().size(0))
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(err) => {
|
||||
|
||||
@@ -131,6 +131,14 @@ impl FileTransferActivity {
|
||||
return;
|
||||
}
|
||||
|
||||
if let Err(err) = reader.finish() {
|
||||
self.log(
|
||||
LogLevel::Error,
|
||||
format!("Failed to finish bridged file read: {err}"),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
if tmpfile.exists() {
|
||||
self.open_path_with(tmpfile.as_path(), open_with);
|
||||
}
|
||||
|
||||
@@ -43,11 +43,10 @@ impl FileInfoPopup {
|
||||
.add_col(SpanStatic::from("File type: "))
|
||||
.add_col(SpanStatic::raw(filetype.clone()).fg(Color::LightGreen));
|
||||
}
|
||||
let (bsize, size): (ByteSize, u64) = (ByteSize(file.metadata().size), file.metadata().size);
|
||||
texts
|
||||
.add_row()
|
||||
.add_col(SpanStatic::from("Size: "))
|
||||
.add_col(SpanStatic::raw(format!("{bsize} ({size})")).fg(Color::Cyan));
|
||||
.add_col(SpanStatic::raw(format_file_size(file)).fg(Color::Cyan));
|
||||
let atime: String = fmt_time(
|
||||
file.metadata().accessed.unwrap_or(UNIX_EPOCH),
|
||||
"%b %d %Y %H:%M:%S",
|
||||
@@ -117,6 +116,13 @@ impl FileInfoPopup {
|
||||
}
|
||||
}
|
||||
|
||||
fn format_file_size(file: &File) -> String {
|
||||
match file.metadata().size {
|
||||
Some(size) => format!("{bsize} ({size})", bsize = ByteSize(size), size = size),
|
||||
None => String::from("Unknown"),
|
||||
}
|
||||
}
|
||||
|
||||
impl AppComponent<Msg, NoUserEvent> for FileInfoPopup {
|
||||
fn on(&mut self, ev: &Event<NoUserEvent>) -> Option<Msg> {
|
||||
match ev {
|
||||
@@ -128,3 +134,17 @@ impl AppComponent<Msg, NoUserEvent> for FileInfoPopup {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use remotefs::fs::Metadata;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn formats_unknown_file_size_as_unknown() {
|
||||
let file = File::new("/tmp/unknown", Metadata::default());
|
||||
|
||||
assert_eq!(format_file_size(&file), "Unknown");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,7 +16,7 @@ use bytesize::ByteSize;
|
||||
pub struct TransferProgress {
|
||||
files_total: usize,
|
||||
files_completed: usize,
|
||||
cur_file_size: usize,
|
||||
cur_file_size: Option<usize>,
|
||||
cur_file_written: usize,
|
||||
total_bytes_written: usize,
|
||||
pub(crate) started: Instant,
|
||||
@@ -27,7 +27,7 @@ impl Default for TransferProgress {
|
||||
Self {
|
||||
files_total: 0,
|
||||
files_completed: 0,
|
||||
cur_file_size: 0,
|
||||
cur_file_size: None,
|
||||
cur_file_written: 0,
|
||||
total_bytes_written: 0,
|
||||
started: Instant::now(),
|
||||
@@ -41,13 +41,15 @@ impl fmt::Display for TransferProgress {
|
||||
0 => String::from("--:--"),
|
||||
seconds => format!("{:02}:{:02}", seconds / 60, seconds % 60),
|
||||
};
|
||||
let total = self.cur_file_size.map_or_else(
|
||||
|| String::from("?"),
|
||||
|size| ByteSize(size as u64).to_string(),
|
||||
);
|
||||
write!(
|
||||
f,
|
||||
"{} / {} — {:.1}% — ETA {} ({}/s)",
|
||||
"{} / {total} — {:.1}% — ETA {eta} ({}/s)",
|
||||
ByteSize(self.cur_file_written as u64),
|
||||
ByteSize(self.cur_file_size as u64),
|
||||
self.calc_partial_progress() * 100.0,
|
||||
eta,
|
||||
ByteSize(self.calc_bytes_per_second()),
|
||||
)
|
||||
}
|
||||
@@ -58,15 +60,15 @@ impl TransferProgress {
|
||||
pub fn init(&mut self, files_total: usize) {
|
||||
self.files_total = files_total;
|
||||
self.files_completed = 0;
|
||||
self.cur_file_size = 0;
|
||||
self.cur_file_size = None;
|
||||
self.cur_file_written = 0;
|
||||
self.total_bytes_written = 0;
|
||||
self.started = Instant::now();
|
||||
}
|
||||
|
||||
/// Begin a new file with a known size.
|
||||
pub fn start_file(&mut self, size: usize) {
|
||||
self.cur_file_size = size;
|
||||
/// Begin a new file with an optional advertised size.
|
||||
pub fn start_file(&mut self, size: Option<u64>) {
|
||||
self.cur_file_size = size.and_then(|size| usize::try_from(size).ok());
|
||||
self.cur_file_written = 0;
|
||||
}
|
||||
|
||||
@@ -83,7 +85,7 @@ impl TransferProgress {
|
||||
/// fraction in [`Self::calc_full_progress`].
|
||||
pub fn finish_file(&mut self) {
|
||||
self.files_completed += 1;
|
||||
self.cur_file_size = 0;
|
||||
self.cur_file_size = None;
|
||||
self.cur_file_written = 0;
|
||||
}
|
||||
|
||||
@@ -92,25 +94,26 @@ impl TransferProgress {
|
||||
self.files_completed += 1;
|
||||
}
|
||||
|
||||
/// Fraction of the current file written (0.0..=1.0). Zero-byte file => 1.0.
|
||||
/// Fraction of the current file written (0.0..=1.0).
|
||||
pub fn calc_partial_progress(&self) -> f64 {
|
||||
if self.cur_file_size == 0 {
|
||||
return 1.0;
|
||||
match self.cur_file_size {
|
||||
None => 0.0,
|
||||
Some(0) => 1.0,
|
||||
Some(size) => (self.cur_file_written as f64 / size as f64).min(1.0),
|
||||
}
|
||||
(self.cur_file_written as f64 / self.cur_file_size as f64).min(1.0)
|
||||
}
|
||||
|
||||
/// Overall progress (0.0..=1.0): file-weighted with intra-file interpolation.
|
||||
///
|
||||
/// The current file only contributes a fraction while it is genuinely in
|
||||
/// progress (`cur_file_size > 0` and not all files completed). A finished
|
||||
/// file clears `cur_file_size` (see [`Self::finish_file`]) so it is counted
|
||||
/// progress (a known size and not all files completed). A finished file
|
||||
/// clears `cur_file_size` (see [`Self::finish_file`]) so it is counted
|
||||
/// exactly once via `files_completed`.
|
||||
pub fn calc_full_progress(&self) -> f64 {
|
||||
if self.files_total == 0 {
|
||||
return 0.0;
|
||||
}
|
||||
let cur_fraction = if self.cur_file_size == 0 || self.files_completed >= self.files_total {
|
||||
let cur_fraction = if self.files_completed >= self.files_total {
|
||||
0.0
|
||||
} else {
|
||||
self.calc_partial_progress()
|
||||
@@ -234,7 +237,7 @@ mod test {
|
||||
assert!(progress.is_single_file());
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
|
||||
progress.start_file(1024);
|
||||
progress.start_file(Some(1024));
|
||||
assert_eq!(progress.calc_partial_progress(), 0.0);
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
|
||||
@@ -258,13 +261,13 @@ mod test {
|
||||
assert!(!progress.is_single_file());
|
||||
|
||||
// File 1 fully transferred => full ≈ 0.25
|
||||
progress.start_file(1000);
|
||||
progress.start_file(Some(1000));
|
||||
progress.add_bytes(1000);
|
||||
progress.finish_file();
|
||||
assert!((progress.calc_full_progress() - 0.25).abs() < 1e-9);
|
||||
|
||||
// File 2 half transferred => partial ≈ 0.5, full ≈ 0.375
|
||||
progress.start_file(1000);
|
||||
progress.start_file(Some(1000));
|
||||
progress.add_bytes(500);
|
||||
assert!((progress.calc_partial_progress() - 0.5).abs() < 1e-9);
|
||||
assert!((progress.calc_full_progress() - 0.375).abs() < 1e-9);
|
||||
@@ -276,7 +279,7 @@ mod test {
|
||||
progress.init(2);
|
||||
|
||||
// One file actually transferred.
|
||||
progress.start_file(100);
|
||||
progress.start_file(Some(100));
|
||||
progress.add_bytes(100);
|
||||
progress.finish_file();
|
||||
|
||||
@@ -294,15 +297,34 @@ mod test {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
|
||||
progress.start_file(0);
|
||||
progress.start_file(Some(0));
|
||||
assert!((progress.calc_partial_progress() - 1.0).abs() < 1e-9);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_unknown_size() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
progress.start_file(None);
|
||||
progress.add_bytes(256);
|
||||
|
||||
assert_eq!(progress.calc_partial_progress(), 0.0);
|
||||
assert_eq!(progress.calc_full_progress(), 0.0);
|
||||
assert_eq!(progress.calc_eta(), 0);
|
||||
assert_eq!(progress.total_bytes_written(), 256);
|
||||
assert!(progress.to_string().contains("?"));
|
||||
assert!(!progress.to_string().contains("0 B"));
|
||||
|
||||
progress.finish_file();
|
||||
assert_eq!(progress.files_completed(), 1);
|
||||
assert_eq!(progress.calc_full_progress(), 1.0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_transfer_progress_timing() {
|
||||
let mut progress = TransferProgress::default();
|
||||
progress.init(1);
|
||||
progress.start_file(1024);
|
||||
progress.start_file(Some(1024));
|
||||
|
||||
progress.started = progress
|
||||
.started
|
||||
|
||||
@@ -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}",);
|
||||
|
||||
@@ -10,7 +10,7 @@ use bytesize::ByteSize;
|
||||
use remotefs::fs::File;
|
||||
use thiserror::Error;
|
||||
|
||||
use crate::host::HostError;
|
||||
use crate::host::{HostError, HostReader, HostWriter};
|
||||
use crate::ui::activities::filetransfer::{FileTransferActivity, LogLevel};
|
||||
use crate::utils::fmt::fmt_millis;
|
||||
|
||||
@@ -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
|
||||
@@ -453,8 +457,8 @@ impl FileTransferActivity {
|
||||
host: &File,
|
||||
remote: &Path,
|
||||
file_name: String,
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
mut reader: HostReader,
|
||||
mut writer: HostWriter,
|
||||
) -> Result<(), TransferErrorReason> {
|
||||
// Write file
|
||||
let file_size = self
|
||||
@@ -463,18 +467,16 @@ impl FileTransferActivity {
|
||||
.fs
|
||||
.stat(host.path())
|
||||
.map_err(TransferErrorReason::HostError)
|
||||
.map(|x| x.metadata().size as usize)?;
|
||||
.map(|x| x.metadata().size)?;
|
||||
// Init transfer
|
||||
self.transfer.progress.start_file(file_size);
|
||||
let file_started = Instant::now();
|
||||
|
||||
// Write remote file
|
||||
let mut total_bytes_written: usize = 0;
|
||||
let mut last_redraw: Instant = Instant::now();
|
||||
let mut last_input_event_fetch: Option<Instant> = None;
|
||||
// While the entire file hasn't been completely written,
|
||||
// Or filetransfer has been aborted
|
||||
while total_bytes_written < file_size && !self.transfer.aborted() {
|
||||
// Read until EOF or until the transfer is aborted.
|
||||
while !self.transfer.aborted() {
|
||||
// Handle input events (each 500ms) or if never fetched before
|
||||
if last_input_event_fetch.is_none()
|
||||
|| last_input_event_fetch
|
||||
@@ -490,33 +492,17 @@ impl FileTransferActivity {
|
||||
}
|
||||
// Read till you can
|
||||
let mut buffer: [u8; BUFSIZE] = [0; BUFSIZE];
|
||||
let delta: usize = match reader.read(&mut buffer) {
|
||||
Ok(bytes_read) => {
|
||||
total_bytes_written += bytes_read;
|
||||
if bytes_read == 0 {
|
||||
continue;
|
||||
} else {
|
||||
let mut delta: usize = 0;
|
||||
while delta < bytes_read {
|
||||
// Write bytes
|
||||
match writer.write(&buffer[delta..bytes_read]) {
|
||||
Ok(bytes) => {
|
||||
delta += bytes;
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(TransferErrorReason::RemoteIoError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(TransferErrorReason::HostIoError(err));
|
||||
}
|
||||
};
|
||||
let bytes_read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(TransferErrorReason::HostIoError)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.map_err(TransferErrorReason::RemoteIoError)?;
|
||||
// Increase progress
|
||||
self.transfer.progress.add_bytes(delta);
|
||||
self.transfer.progress.add_bytes(bytes_read);
|
||||
// Redraw at most every 100ms to keep UI responsive for large files
|
||||
if last_redraw.elapsed().as_millis() >= 100 {
|
||||
self.update_progress_bar(format!("Uploading \"{file_name}\"…"));
|
||||
@@ -524,17 +510,14 @@ impl FileTransferActivity {
|
||||
last_redraw = Instant::now();
|
||||
}
|
||||
}
|
||||
// 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}\""),
|
||||
);
|
||||
}
|
||||
// if upload was abrupted, return error
|
||||
if self.transfer.aborted() {
|
||||
return Err(TransferErrorReason::Abrupted);
|
||||
}
|
||||
writer.flush().map_err(TransferErrorReason::RemoteIoError)?;
|
||||
reader.finish().map_err(TransferErrorReason::HostError)?;
|
||||
// Finalize stream
|
||||
handle_remote_finalize_result(self.browser.remote_pane_mut().fs.finalize_write(writer))?;
|
||||
// set stat
|
||||
if let Err(err) = self
|
||||
.browser
|
||||
@@ -813,21 +796,17 @@ impl FileTransferActivity {
|
||||
host_bridge: &Path,
|
||||
remote: &File,
|
||||
file_name: String,
|
||||
mut reader: Box<dyn Read + Send>,
|
||||
mut writer: Box<dyn Write + Send>,
|
||||
mut reader: HostReader,
|
||||
mut writer: HostWriter,
|
||||
) -> Result<(), TransferErrorReason> {
|
||||
let mut total_bytes_written: usize = 0;
|
||||
// Init transfer
|
||||
self.transfer
|
||||
.progress
|
||||
.start_file(remote.metadata.size as usize);
|
||||
self.transfer.progress.start_file(remote.metadata.size);
|
||||
let file_started = Instant::now();
|
||||
// Write host_bridge file
|
||||
let mut last_redraw: Instant = Instant::now();
|
||||
let mut last_input_event_fetch: Option<Instant> = None;
|
||||
// While the entire file hasn't been completely read,
|
||||
// Or filetransfer has been aborted
|
||||
while total_bytes_written < remote.metadata.size as usize && !self.transfer.aborted() {
|
||||
// Read until EOF or until the transfer is aborted.
|
||||
while !self.transfer.aborted() {
|
||||
// Handle input events (each 500 ms) or is None
|
||||
if last_input_event_fetch.is_none()
|
||||
|| last_input_event_fetch
|
||||
@@ -843,31 +822,17 @@ impl FileTransferActivity {
|
||||
}
|
||||
// Read till you can
|
||||
let mut buffer: [u8; BUFSIZE] = [0; BUFSIZE];
|
||||
let delta: usize = match reader.read(&mut buffer) {
|
||||
Ok(bytes_read) => {
|
||||
total_bytes_written += bytes_read;
|
||||
if bytes_read == 0 {
|
||||
continue;
|
||||
} else {
|
||||
let mut delta: usize = 0;
|
||||
while delta < bytes_read {
|
||||
// Write bytes
|
||||
match writer.write(&buffer[delta..bytes_read]) {
|
||||
Ok(bytes) => delta += bytes,
|
||||
Err(err) => {
|
||||
return Err(TransferErrorReason::HostIoError(err));
|
||||
}
|
||||
}
|
||||
}
|
||||
delta
|
||||
}
|
||||
}
|
||||
Err(err) => {
|
||||
return Err(TransferErrorReason::RemoteIoError(err));
|
||||
}
|
||||
};
|
||||
let bytes_read = reader
|
||||
.read(&mut buffer)
|
||||
.map_err(TransferErrorReason::RemoteIoError)?;
|
||||
if bytes_read == 0 {
|
||||
break;
|
||||
}
|
||||
writer
|
||||
.write_all(&buffer[..bytes_read])
|
||||
.map_err(TransferErrorReason::HostIoError)?;
|
||||
// Set progress
|
||||
self.transfer.progress.add_bytes(delta);
|
||||
self.transfer.progress.add_bytes(bytes_read);
|
||||
// Redraw at most every 100ms to keep UI responsive for large files
|
||||
if last_redraw.elapsed().as_millis() >= 100 {
|
||||
self.update_progress_bar(format!("Downloading \"{file_name}\""));
|
||||
@@ -880,6 +845,10 @@ impl FileTransferActivity {
|
||||
return Err(TransferErrorReason::Abrupted);
|
||||
}
|
||||
|
||||
writer.flush().map_err(TransferErrorReason::HostIoError)?;
|
||||
reader
|
||||
.finish()
|
||||
.map_err(TransferErrorReason::RemoteHostError)?;
|
||||
// Finalize write
|
||||
self.browser
|
||||
.local_pane_mut()
|
||||
@@ -922,6 +891,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;
|
||||
@@ -936,25 +923,20 @@ mod worklist_test {
|
||||
// equal (`File` derives `PartialEq` over its full metadata, timestamps
|
||||
// included), allowing direct equality assertions against the worklist.
|
||||
let t = SystemTime::UNIX_EPOCH;
|
||||
let metadata = Metadata {
|
||||
accessed: Some(t),
|
||||
created: Some(t),
|
||||
modified: Some(t),
|
||||
file_type: if is_dir {
|
||||
let metadata = Metadata::default()
|
||||
.accessed(t)
|
||||
.created(t)
|
||||
.modified(t)
|
||||
.file_type(if is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
},
|
||||
symlink: None,
|
||||
gid: Some(0),
|
||||
uid: Some(0),
|
||||
mode: Some(UnixPex::from(if is_dir { 0o755 } else { 0o644 })),
|
||||
size: 64,
|
||||
};
|
||||
File {
|
||||
path: PathBuf::from(path),
|
||||
metadata,
|
||||
}
|
||||
})
|
||||
.gid(0)
|
||||
.uid(0)
|
||||
.mode(UnixPex::from(if is_dir { 0o755 } else { 0o644 }))
|
||||
.size(64);
|
||||
File::new(path, metadata)
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
//!
|
||||
//! `Context` is the module which provides all the functionalities related to the UI data holder, called Context
|
||||
|
||||
use ssh2_config::SshConfig;
|
||||
// Locals
|
||||
use tuirealm::terminal::{CrosstermTerminalAdapter, TerminalAdapter};
|
||||
|
||||
@@ -13,13 +14,23 @@ use crate::system::theme_provider::ThemeProvider;
|
||||
|
||||
/// Context holds data structures shared by the activities
|
||||
pub struct Context {
|
||||
/// Parameters used to build the host bridge.
|
||||
host_bridge_params: Option<HostBridgeParams>,
|
||||
/// Parameters used to build the remote file transfer client.
|
||||
remote_params: Option<FileTransferParams>,
|
||||
/// Client for persistent bookmarks, when initialization succeeded.
|
||||
bookmarks_client: Option<BookmarksClient>,
|
||||
/// Client for persisted application configuration.
|
||||
config_client: ConfigClient,
|
||||
/// SSH configuration parsed once during application startup.
|
||||
ssh_config: Option<SshConfig>,
|
||||
/// Shared state managed by UI activities.
|
||||
pub(crate) store: Store,
|
||||
/// Terminal adapter used to render the user interface.
|
||||
pub(crate) terminal: CrosstermTerminalAdapter,
|
||||
/// Provider for the active user interface theme.
|
||||
theme_provider: ThemeProvider,
|
||||
/// Error pending display to the user.
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
@@ -29,6 +40,7 @@ impl Context {
|
||||
bookmarks_client: Option<BookmarksClient>,
|
||||
config_client: ConfigClient,
|
||||
theme_provider: ThemeProvider,
|
||||
ssh_config: Option<SshConfig>,
|
||||
error: Option<String>,
|
||||
) -> Context {
|
||||
let mut terminal = CrosstermTerminalAdapter::new().expect("Could not initialize terminal");
|
||||
@@ -45,6 +57,7 @@ impl Context {
|
||||
config_client,
|
||||
host_bridge_params: None,
|
||||
remote_params: None,
|
||||
ssh_config,
|
||||
store: Store::init(),
|
||||
terminal,
|
||||
theme_provider,
|
||||
@@ -78,6 +91,11 @@ impl Context {
|
||||
&mut self.config_client
|
||||
}
|
||||
|
||||
/// Returns the SSH configuration parsed during application startup.
|
||||
pub fn ssh_config(&self) -> Option<&SshConfig> {
|
||||
self.ssh_config.as_ref()
|
||||
}
|
||||
|
||||
pub(crate) fn store(&self) -> &Store {
|
||||
&self.store
|
||||
}
|
||||
|
||||
+70
-1
@@ -40,6 +40,9 @@ pub(super) static REMOTE_KUBE_OPT_REGEX: Lazy<Regex> =
|
||||
pub(super) static REMOTE_S3_OPT_REGEX: Lazy<Regex> =
|
||||
lazy_regex!(r"(?:(.+[^@])@)(?:([^:]+))(?::([a-zA-Z0-9][^:]+))?(?::([^:]+))?");
|
||||
|
||||
/// Regex matches Google Cloud Storage remote options.
|
||||
pub(super) static REMOTE_GCS_OPT_REGEX: Lazy<Regex> = lazy_regex!(r"^([^:]+)(?::(/.*))?$");
|
||||
|
||||
/// Regex matches SMB remote options on Unix platforms.
|
||||
#[cfg(smb_unix)]
|
||||
pub(super) static REMOTE_SMB_OPT_REGEX: Lazy<Regex> = lazy_regex!(
|
||||
@@ -61,6 +64,15 @@ static SEMVER_REGEX: Lazy<Regex> = lazy_regex!(r"v?((0|[1-9]\d*)\.(0|[1-9]\d*)\.
|
||||
*/
|
||||
static BYTESIZE_REGEX: Lazy<Regex> = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B$");
|
||||
|
||||
/// Parsed remote parameters together with CLI syntax metadata.
|
||||
#[derive(Debug)]
|
||||
pub(crate) struct ParsedRemote {
|
||||
/// Parsed file transfer parameters.
|
||||
pub(crate) file_transfer_params: FileTransferParams,
|
||||
/// Whether the remote address explicitly provided a port.
|
||||
pub(crate) port_explicit: bool,
|
||||
}
|
||||
|
||||
/// Parse remote option string. Returns in case of success a RemoteOptions struct
|
||||
/// For ssh if username is not provided, current user will be used.
|
||||
/// In case of error, message is returned
|
||||
@@ -82,6 +94,10 @@ static BYTESIZE_REGEX: Lazy<Regex> = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B
|
||||
///
|
||||
/// `s3://<bucket-name>@<region>[:profile][:/wrkdir]`
|
||||
///
|
||||
/// For Google Cloud Storage:
|
||||
///
|
||||
/// `gcs://<bucket>[:/working/directory]`
|
||||
///
|
||||
/// For SMB:
|
||||
///
|
||||
/// on UNIX derived (macos, linux, ...)
|
||||
@@ -92,8 +108,20 @@ static BYTESIZE_REGEX: Lazy<Regex> = lazy_regex!(r"(:?([0-9])+)( )*(:?[KMGTP])?B
|
||||
///
|
||||
/// `\\<address>\<share>[\path]`
|
||||
///
|
||||
#[cfg_attr(
|
||||
not(test),
|
||||
expect(
|
||||
dead_code,
|
||||
reason = "The public parser API is retained while CLI callers need port metadata."
|
||||
)
|
||||
)]
|
||||
pub fn parse_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
remote::parse_remote_opt(s)
|
||||
parse_remote_opt_with_metadata(s).map(|parsed| parsed.file_transfer_params)
|
||||
}
|
||||
|
||||
/// Parse a remote option while retaining metadata needed by CLI precedence rules.
|
||||
pub(crate) fn parse_remote_opt_with_metadata(s: &str) -> Result<ParsedRemote, String> {
|
||||
remote::parse_remote_opt_with_metadata(s)
|
||||
}
|
||||
|
||||
/// Parse semver string
|
||||
@@ -345,6 +373,20 @@ mod tests {
|
||||
assert!(result.remote_path.is_none());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parsed_remote_should_track_whether_the_port_was_explicit() {
|
||||
for (remote, port_explicit) in [
|
||||
("scp://host", false),
|
||||
("scp://host:/path", false),
|
||||
("scp://host:22", true),
|
||||
("scp://host:2222", true),
|
||||
] {
|
||||
let parsed = remote::parse_remote_opt_with_metadata(remote).unwrap();
|
||||
|
||||
assert_eq!(parsed.port_explicit, port_explicit, "{remote}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_should_parse_webdav_opt() {
|
||||
let result =
|
||||
@@ -472,6 +514,33 @@ mod tests {
|
||||
assert_eq!(params.region.as_deref().unwrap(), "eu-central-1");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_google_cloud_storage_address() {
|
||||
let result = parse_remote_opt("gcs://my-bucket").unwrap();
|
||||
let params = result.params.gcs_params().unwrap();
|
||||
|
||||
assert_eq!(result.protocol, FileTransferProtocol::GoogleCloudStorage);
|
||||
assert_eq!(result.remote_path, None);
|
||||
assert_eq!(params.bucket_name, "my-bucket");
|
||||
assert_eq!(params.endpoint, "https://storage.googleapis.com");
|
||||
assert_eq!(params.service_account_key, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_google_cloud_storage_working_directory() {
|
||||
let result = parse_remote_opt("gcs://my-bucket:/assets/images").unwrap();
|
||||
|
||||
assert_eq!(
|
||||
result.remote_path.as_deref(),
|
||||
Some(std::path::Path::new("/assets/images"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_reject_google_cloud_storage_address_without_bucket() {
|
||||
assert!(parse_remote_opt("gcs://:/assets").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn should_parse_kube_address() {
|
||||
let result = parse_remote_opt("kube://my-namespace@http://localhost:1234$/tmp")
|
||||
|
||||
@@ -9,6 +9,7 @@ pub(super) fn default_port_for_protocol(protocol: FileTransferProtocol) -> u16 {
|
||||
match protocol {
|
||||
FileTransferProtocol::Ftp(_) => 21,
|
||||
FileTransferProtocol::Scp | FileTransferProtocol::Sftp => 22,
|
||||
FileTransferProtocol::GoogleCloudStorage => 22,
|
||||
_ => 22,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -11,25 +11,29 @@ use super::credentials::{optional_capture, required_capture};
|
||||
use super::ports::{default_port_for_protocol, parse_port};
|
||||
use super::protocol::parse_remote_opt_protocol;
|
||||
use super::{
|
||||
REMOTE_GENERIC_OPT_REGEX, REMOTE_KUBE_OPT_REGEX, REMOTE_S3_OPT_REGEX, REMOTE_WEBDAV_OPT_REGEX,
|
||||
REMOTE_GCS_OPT_REGEX, REMOTE_GENERIC_OPT_REGEX, REMOTE_KUBE_OPT_REGEX, REMOTE_S3_OPT_REGEX,
|
||||
REMOTE_WEBDAV_OPT_REGEX,
|
||||
};
|
||||
#[cfg(smb)]
|
||||
use crate::filetransfer::params::SmbParams;
|
||||
use crate::filetransfer::params::{
|
||||
AwsS3Params, GenericProtocolParams, KubeProtocolParams, ProtocolParams, WebDAVProtocolParams,
|
||||
AwsS3Params, GenericProtocolParams, GoogleCloudStorageParams, KubeProtocolParams,
|
||||
ProtocolParams, WebDAVProtocolParams,
|
||||
};
|
||||
use crate::filetransfer::{FileTransferParams, FileTransferProtocol};
|
||||
#[cfg(not(test))]
|
||||
use crate::system::config_client::ConfigClient;
|
||||
#[cfg(not(test))]
|
||||
use crate::system::environment;
|
||||
use crate::utils::parser::ParsedRemote;
|
||||
|
||||
pub(super) fn parse_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
pub(super) fn parse_remote_opt_with_metadata(s: &str) -> Result<ParsedRemote, String> {
|
||||
let default_protocol = default_protocol();
|
||||
let (protocol, remote) = parse_remote_opt_protocol(s, default_protocol)?;
|
||||
|
||||
match protocol {
|
||||
let file_transfer_params = match protocol {
|
||||
FileTransferProtocol::AwsS3 => parse_s3_remote_opt(remote.as_str()),
|
||||
FileTransferProtocol::GoogleCloudStorage => parse_gcs_remote_opt(remote.as_str()),
|
||||
FileTransferProtocol::Kube => parse_kube_remote_opt(remote.as_str()),
|
||||
#[cfg(smb)]
|
||||
FileTransferProtocol::Smb => parse_smb_remote_opts(remote.as_str()),
|
||||
@@ -42,8 +46,13 @@ pub(super) fn parse_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
|
||||
parse_webdav_remote_opt(remote.as_str(), prefix)
|
||||
}
|
||||
protocol => parse_generic_remote_opt(remote.as_str(), protocol),
|
||||
}
|
||||
protocol => return parse_generic_remote_opt(remote.as_str(), protocol),
|
||||
}?;
|
||||
|
||||
Ok(ParsedRemote {
|
||||
file_transfer_params,
|
||||
port_explicit: false,
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(not(test))]
|
||||
@@ -68,13 +77,14 @@ fn default_protocol() -> FileTransferProtocol {
|
||||
fn parse_generic_remote_opt(
|
||||
s: &str,
|
||||
protocol: FileTransferProtocol,
|
||||
) -> Result<FileTransferParams, String> {
|
||||
) -> Result<ParsedRemote, String> {
|
||||
let groups = REMOTE_GENERIC_OPT_REGEX
|
||||
.captures(s)
|
||||
.ok_or_else(|| String::from("Bad remote host syntax!"))?;
|
||||
|
||||
let username = optional_capture(&groups, 1);
|
||||
let address = required_capture(&groups, 2, "address")?;
|
||||
let port_explicit = groups.get(3).is_some();
|
||||
let port = parse_port(groups.get(3), default_port_for_protocol(protocol))?;
|
||||
let remote_path = groups.get(4).map(|group| PathBuf::from(group.as_str()));
|
||||
let params = ProtocolParams::Generic(
|
||||
@@ -84,7 +94,10 @@ fn parse_generic_remote_opt(
|
||||
.username(username),
|
||||
);
|
||||
|
||||
Ok(FileTransferParams::new(protocol, params).remote_path(remote_path))
|
||||
Ok(ParsedRemote {
|
||||
file_transfer_params: FileTransferParams::new(protocol, params).remote_path(remote_path),
|
||||
port_explicit,
|
||||
})
|
||||
}
|
||||
|
||||
fn parse_webdav_remote_opt(s: &str, prefix: &str) -> Result<FileTransferParams, String> {
|
||||
@@ -122,6 +135,20 @@ fn parse_s3_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
.remote_path(remote_path))
|
||||
}
|
||||
|
||||
fn parse_gcs_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
let groups = REMOTE_GCS_OPT_REGEX
|
||||
.captures(s)
|
||||
.ok_or_else(|| String::from("Bad Google Cloud Storage syntax!"))?;
|
||||
let bucket = required_capture(&groups, 1, "bucket")?;
|
||||
let remote_path = groups.get(2).map(|group| PathBuf::from(group.as_str()));
|
||||
|
||||
Ok(FileTransferParams::new(
|
||||
FileTransferProtocol::GoogleCloudStorage,
|
||||
ProtocolParams::GoogleCloudStorage(GoogleCloudStorageParams::new(bucket)),
|
||||
)
|
||||
.remote_path(remote_path))
|
||||
}
|
||||
|
||||
fn parse_kube_remote_opt(s: &str) -> Result<FileTransferParams, String> {
|
||||
let groups = REMOTE_KUBE_OPT_REGEX
|
||||
.captures(s)
|
||||
|
||||
+74
-1
@@ -5,6 +5,18 @@
|
||||
|
||||
use ssh2_config::{ParseRule, SshConfig};
|
||||
|
||||
/// The standard port used when an SSH configuration does not define one.
|
||||
const DEFAULT_SSH_PORT: u16 = 22;
|
||||
|
||||
/// Connection parameters resolved from an SSH host configuration.
|
||||
#[derive(Debug, PartialEq, Eq)]
|
||||
pub(crate) struct SshHostParams {
|
||||
/// Resolved SSH port.
|
||||
pub(crate) port: u16,
|
||||
/// Resolved SSH username, when configured.
|
||||
pub(crate) username: Option<String>,
|
||||
}
|
||||
|
||||
/// Parses an OpenSSH-style config file into an `ssh2_config::SshConfig`.
|
||||
pub fn parse_ssh2_config(path: &str) -> Result<SshConfig, String> {
|
||||
use std::fs::File;
|
||||
@@ -18,10 +30,23 @@ pub fn parse_ssh2_config(path: &str) -> Result<SshConfig, String> {
|
||||
.map_err(|e| format!("Failed to parse ssh2 config: {e}"))
|
||||
}
|
||||
|
||||
/// Resolves SSH connection parameters for a host from the startup-parsed configuration.
|
||||
pub(crate) fn resolve_ssh_host_params(config: Option<&SshConfig>, host: &str) -> SshHostParams {
|
||||
let params = config.map(|config| config.query(host));
|
||||
|
||||
SshHostParams {
|
||||
port: params
|
||||
.as_ref()
|
||||
.and_then(|params| params.port)
|
||||
.unwrap_or(DEFAULT_SSH_PORT),
|
||||
username: params.and_then(|params| params.user),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
|
||||
use crate::utils::ssh::parse_ssh2_config;
|
||||
use super::{SshHostParams, parse_ssh2_config, resolve_ssh_host_params};
|
||||
use crate::utils::test_helpers;
|
||||
|
||||
#[test]
|
||||
@@ -53,4 +78,52 @@ Host test
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_host_params_should_resolve_exact_and_wildcard_hosts() {
|
||||
let ssh_config_file = test_helpers::create_sample_file_with_content(
|
||||
r#"
|
||||
Host exact-host
|
||||
Port 2222
|
||||
User exact-user
|
||||
|
||||
Host *.example.com
|
||||
Port 2200
|
||||
User wildcard-user
|
||||
"#,
|
||||
);
|
||||
let config = parse_ssh2_config(&ssh_config_file.path().to_string_lossy())
|
||||
.expect("test SSH configuration should parse");
|
||||
|
||||
assert_eq!(
|
||||
resolve_ssh_host_params(Some(&config), "exact-host"),
|
||||
SshHostParams {
|
||||
port: 2222,
|
||||
username: Some("exact-user".to_string()),
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
resolve_ssh_host_params(Some(&config), "server.example.com"),
|
||||
SshHostParams {
|
||||
port: 2200,
|
||||
username: Some("wildcard-user".to_string()),
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ssh_host_params_should_default_when_configuration_has_no_values() {
|
||||
let ssh_config_file =
|
||||
test_helpers::create_sample_file_with_content("Host unconfigured-host\n");
|
||||
let config = parse_ssh2_config(&ssh_config_file.path().to_string_lossy())
|
||||
.expect("test SSH configuration should parse");
|
||||
|
||||
assert_eq!(
|
||||
resolve_ssh_host_params(Some(&config), "unconfigured-host"),
|
||||
SshHostParams {
|
||||
port: 22,
|
||||
username: None,
|
||||
}
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -14,10 +14,7 @@ pub fn create_sample_file_entry() -> (File, NamedTempFile) {
|
||||
// Write
|
||||
let tmpfile = create_sample_file();
|
||||
(
|
||||
File {
|
||||
path: tmpfile.path().to_path_buf(),
|
||||
metadata: Metadata::default(),
|
||||
},
|
||||
File::new(tmpfile.path().to_path_buf(), Metadata::default()),
|
||||
tmpfile,
|
||||
)
|
||||
}
|
||||
@@ -58,14 +55,14 @@ pub fn make_dir_at(dir: &Path, dirname: &str) -> std::io::Result<()> {
|
||||
/// Create a File at specified path
|
||||
pub fn make_fsentry<P: AsRef<Path>>(path: P, is_dir: bool) -> File {
|
||||
let path: PathBuf = path.as_ref().to_path_buf();
|
||||
File {
|
||||
File::new(
|
||||
path,
|
||||
metadata: Metadata::default().file_type(if is_dir {
|
||||
Metadata::default().file_type(if is_dir {
|
||||
FileType::Directory
|
||||
} else {
|
||||
FileType::File
|
||||
}),
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
/// Open a file with two handlers, the first is to read, the second is to write
|
||||
|
||||
Reference in New Issue
Block a user