240 Commits

Author SHA1 Message Date
veeso
bd99665d1c AUR ok 2021-02-28 21:23:22 +01:00
veeso
93bab299ec debian 8 is too old? 2021-02-28 21:07:51 +01:00
veeso
eb33f93322 rpm name changed 2021-02-28 16:24:24 +01:00
veeso
d165b699f0 Removed archlinux from build (it doesn't work anymore 2021-02-28 16:01:07 +01:00
veeso
7817295d8e The archlinux image is so broken 2021-02-28 15:12:34 +01:00
veeso
a986e531d9 Docker is broken 2021-02-28 15:11:08 +01:00
veeso
8a3b652dcd It seems they broke the archlinux docker image with latest version (gg) 2021-02-28 15:07:44 +01:00
Christian Visintin
efbea63154 Merge pull request #7 from veeso/fetch-new-release
Check for updates through Github API
2021-02-28 14:47:55 +01:00
veeso
61045fa548 Check for updates OK 2021-02-28 13:10:59 +01:00
veeso
da5e1f315d Show new version available in auth activity 2021-02-28 13:01:51 +01:00
veeso
85c57ce027 Handle check for updates in setup activity 2021-02-28 12:47:55 +01:00
veeso
6682c07eb6 Added check_for_updates to config 2021-02-28 12:44:00 +01:00
veeso
4e887c3429 Git: check for new updates (utils) 2021-02-28 12:33:12 +01:00
veeso
6435271be8 Parse semver util 2021-02-28 12:21:28 +01:00
veeso
c9a77fa65d 0.3.3 is ready for release I guess 2021-02-27 20:57:07 +01:00
veeso
cc5399d36e Cargo clippy 2021-02-27 20:49:20 +01:00
veeso
ca1aa5675a Try test threads: 1 2021-02-27 20:30:50 +01:00
ChristianVisintin
e21bfbbd14 Use a regex to parse the remote host args 2021-02-26 16:56:03 +01:00
ChristianVisintin
e948d598b0 Convert to lowercase when sorting bookmarks 2021-02-26 08:13:38 +01:00
Christian Visintin
0173d67a3b Merge pull request #6 from veeso/fmt-props
Format key attributes
2021-02-25 20:08:01 +01:00
veeso
025547a3dc Format key attributes 2021-02-25 17:47:50 +01:00
veeso
af830d603d Now bookmarks and recents are sorted in the UI (bookmarks are sorted by name; recents are sorted by connection datetime) 2021-02-25 16:15:06 +01:00
veeso
669fd23868 Support for older distributions 2021-02-25 14:43:27 +01:00
veeso
4ff7fc079c Added CLI options to set starting working directory on both local and remote hosts 2021-02-25 14:27:34 +01:00
ChristianVisintin
7f24d6db5c Default choice for deleting file set to NO (way too easy to delete files by mistake) 2021-02-16 12:53:28 +01:00
ChristianVisintin
8c8d01c29c working on 0.3.3 2021-02-16 12:50:04 +01:00
ChristianVisintin
d23e6bb60c centos7 dockerfile 2021-01-28 12:03:11 +01:00
veeso
87aa900bc6 0.3.2 2021-01-24 20:48:46 +01:00
veeso
e8e4cd22a1 0.3.2 ready 2021-01-24 20:31:24 +01:00
veeso
780cf592e4 Updated dependencies 2021-01-24 12:04:27 +01:00
Christian Visintin
45db58a0b3 Merge pull request #5 from veeso/ls-fmt
File explorer formatter
2021-01-24 11:59:31 +01:00
veeso
f5218bc582 test_utils_fmt_path_elide: don't run on windows 2021-01-24 11:48:00 +01:00
veeso
c5e2e02415 Optimized formatter: instead of replacing in fmt_str, keep 'prefix' in the call chain 2021-01-24 11:29:20 +01:00
veeso
e088772685 Docs 2021-01-23 16:38:46 +01:00
veeso
859daa3107 Clippy 2021-01-23 16:38:36 +01:00
veeso
56c580fc80 Use file_fmt in explorer activity 2021-01-23 16:26:25 +01:00
veeso
7a9ee697ff Added fmt_file to setupt activity 2021-01-23 16:22:32 +01:00
veeso
c16a2f6441 Improved getters/setters config client 2021-01-23 16:21:30 +01:00
veeso
b3c4385617 Added to ConfigClient getters/setters for file_fmt 2021-01-23 16:09:13 +01:00
veeso
e92370bd05 Added file_fmt to configuration 2021-01-23 16:00:41 +01:00
veeso
da0d5231bf Use formatter to fmt fs entries instead of fmt::Display trait 2021-01-23 15:51:46 +01:00
veeso
c1f6308795 Explorer formatter module 2021-01-23 15:41:07 +01:00
veeso
54ab24fc0c fmt_path_elide 2021-01-23 15:03:43 +01:00
veeso
d99efb9de4 SCP File transfer: when listing directory entries, check if a symlink points to a directory or to a file 2021-01-23 12:20:34 +01:00
ChristianVisintin
0c9ed38eb7 Solved file index in explorer files at start of termscp, in case the first entry is an hidden file 2021-01-19 09:13:08 +01:00
ChristianVisintin
ec801f1555 Working on 0.3.2 2021-01-19 09:12:59 +01:00
veeso
e0d8879961 typos 2021-01-18 19:09:54 +01:00
veeso
3f2be65a6c termscp 0.3.1 2021-01-18 17:56:13 +01:00
veeso
a5a745e444 0.3.1 2021-01-18 17:36:53 +01:00
Christian Visintin
7ee142314e Merge pull request #4 from Fenex/refactoring/1
Refactoring FtpFileTransfer::parse_unix_list_line
2021-01-18 14:25:24 +01:00
Christian Visintin
c412d98ec7 Merge branch '0.3.1' into refactoring/1 2021-01-18 08:07:00 +01:00
Vitaliy Busko
d7e5eacd79 Refactoring ScpFileTransfer::parse_ls_output 2021-01-18 11:31:35 +07:00
Vitaliy Busko
367fb235f6 Refactoring FtpFileTransfer::parse_unix_list_line 2021-01-18 10:51:22 +07:00
Christian Visintin
d68d63b978 Merge pull request #3 from veeso/keystorage
Keystorage
2021-01-16 18:10:18 +01:00
ChristianVisintin
23ca2baa8c Cargo clippy 2021-01-16 18:02:12 +01:00
ChristianVisintin
ac02928e69 Don't run bookmarks tests on macos 2021-01-16 17:37:03 +01:00
ChristianVisintin
cb20589b01 Macos test thread (?) 2021-01-16 17:27:07 +01:00
ChristianVisintin
1acbf89717 keyring ok 2021-01-16 17:13:41 +01:00
ChristianVisintin
08d8a3621c Keyring storage in bookmarks client (if possible) 2021-01-16 16:57:00 +01:00
ChristianVisintin
0192b86422 Check if supported (test) 2021-01-16 16:07:53 +01:00
ChristianVisintin
0e4caaecfd Keyring storage 2021-01-16 16:07:11 +01:00
ChristianVisintin
215927d432 Fixed copyright header 2021-01-16 15:37:29 +01:00
ChristianVisintin
eee08bd623 Key storage (file) 2021-01-16 15:37:19 +01:00
ChristianVisintin
76fdd9864c Fixed copyright header 2021-01-16 15:13:58 +01:00
ChristianVisintin
350443ec99 SCP file transfer: fixed possible wrong file size when sending file, due to a possible incoherent size between the file explorer and the actual file size 2021-01-16 11:49:59 +01:00
ChristianVisintin
928fc1b450 Solved index of files list no more kept after 0.3.0 (use set_abs_index instead) 2021-01-16 11:35:33 +01:00
ChristianVisintin
03e1bf53d0 Solved index of files list no more kept after 0.3.0 2021-01-16 11:16:31 +01:00
ChristianVisintin
9330025d07 Connection timeout for SFTP/SCP clients 2021-01-16 10:58:07 +01:00
ChristianVisintin
bf56a269e0 Replaced Box<dyn Iterator... with impl Iterator 2021-01-16 10:37:53 +01:00
ChristianVisintin
0393c1a850 working on 0.3.1... 2021-01-16 10:33:34 +01:00
ChristianVisintin
69ece00ae2 working on 0.3.1... 2021-01-16 10:31:24 +01:00
Christian Visintin
97656536d4 Cargo build requirements 2021-01-13 10:49:46 +01:00
Christian Visintin
94b78d85ef Merge pull request #1 from Byron/main
run `cargo diet` to reduce crate size by 85%
2021-01-11 08:11:52 +01:00
Sebastian Thiel
ba3a888d26 run cargo diet to reduce crate size by 85%
I noticed the crate is pretty big and took a quick look, here
is the outcome of slimming it down.

```
➜  termscp git:(main) cargo diet
┌───────────────────────────────────────────┬─────────────┐
│ File                                      │ Size (Byte) │
├───────────────────────────────────────────┼─────────────┤
│ codecov.yml                               │          96 │
│ .github/ISSUE_TEMPLATE/feature_request.md │         203 │
│ dist/deb.sh                               │         210 │
│ .github/workflows/macos.yml               │         319 │
│ .github/workflows/windows.yml             │         321 │
│ dist/rpm.sh                               │         336 │
│ dist/build/README.md                      │         345 │
│ .gitignore                                │         356 │
│ dist/build/x86_64/Dockerfile              │         509 │
│ dist/pkgs/arch/.SRCINFO                   │         511 │
│ .github/workflows/aur-pub.yml             │         570 │
│ dist/pkgs/arch/PKGBUILD                   │         580 │
│ .github/ISSUE_TEMPLATE/bug_report.md      │         598 │
│ dist/build/x86_64_archlinux/Dockerfile    │         905 │
│ .github/workflows/linux.yml               │        1013 │
│ .github/PULL_REQUEST_TEMPLATE.md          │        1093 │
│ dist/build/deploy.sh                      │        1291 │
│ docs/drawio/UI.drawio                     │        1993 │
│ CODE_OF_CONDUCT.md                        │        3368 │
│ CONTRIBUTING.md                           │       10756 │
│ assets/images/bookmarks.gif               │      298453 │
│ assets/images/auth.gif                    │      321769 │
│ assets/images/explorer.gif                │      650583 │
│ assets/images/config.gif                  │      705780 │
│ assets/images/text-editor.gif             │     1898000 │
└───────────────────────────────────────────┴─────────────┘
Saved 85% or 3.9 MB in 25 files (of 4.6 MB and 75 files in entire crate)
```

Please let me know if you would like some other files to be included
or whatever else is needed to make this PR mergeable.

Thanks :).
2021-01-11 09:09:50 +08:00
veeso
d62a6e98c8 0.3.0 2021-01-10 18:22:53 +01:00
veeso
eeed99b013 Merge branch '0.3.0' into main 2021-01-10 17:31:31 +01:00
veeso
2b54326334 0.3.0 ready 2021-01-10 17:30:20 +01:00
veeso
2bd3d33ff6 Updated copyright 2021-01-09 20:45:06 +01:00
veeso
c54a9ef5c9 termscp 0.3.0 ready 2021-01-09 20:35:55 +01:00
veeso
a7ae0159cc config gif 2021-01-09 15:28:20 +01:00
veeso
f981602221 Definetely fixed FTP issues 2021-01-09 15:05:49 +01:00
veeso
fa5468be4a Fixed time check tests 2021-01-09 14:29:30 +01:00
veeso
591182414f Updated references to veeso 2021-01-08 22:02:40 +01:00
veeso
49790b4704 Updated references to veeso 2021-01-02 13:04:49 +01:00
ChristianVisintin
daa3b3e549 Fixed 0 B/S transfer rate displayed after completing download in less than 1 second 2020-12-28 22:54:28 +01:00
ChristianVisintin
120dc8ecb4 Fixed buffer sizes for transfers 2020-12-28 22:45:28 +01:00
ChristianVisintin
06a2373776 parse dos line test 2020-12-27 20:42:52 +01:00
ChristianVisintin
32ae5cc182 Removed test for macos 2020-12-27 19:28:41 +01:00
ChristianVisintin
6975beaf30 Fixed file extension not found in SCP/FTP 2020-12-27 15:08:57 +01:00
ChristianVisintin
c141c6c44d Added LIST command parser for Windows server (DOS-like syntax) 2020-12-27 15:08:42 +01:00
ChristianVisintin
68cd77a9b3 Added utils::parser::parse_datetime 2020-12-27 12:01:20 +01:00
ChristianVisintin
e20a78acef Don't collapse bookmarks tabs 2020-12-27 11:05:01 +01:00
ChristianVisintin
6dd4cfaa3c InputMode as Option<Popup> in AuthActivity 2020-12-27 11:03:44 +01:00
ChristianVisintin
d756bf7786 InputMode as Option<Popup> in FileTransferActivity 2020-12-27 10:59:12 +01:00
ChristianVisintin
65e7ff22f7 Explorers: append '/' to directories name 2020-12-27 10:47:11 +01:00
ChristianVisintin
655084c5f6 typo 2020-12-27 10:36:48 +01:00
ChristianVisintin
09bc8a92a2 show_hidden_files and group_dirs in termscp configuration; instantiate FileExplorer based on current configuration in FileTransferActivity 2020-12-27 10:31:33 +01:00
ChristianVisintin
99fd0b199d FileTransferActivity: sort files with <B> 2020-12-26 21:47:48 +01:00
ChristianVisintin
740d906eb3 ToString, FromStr for FileSorting and GroupDirs 2020-12-26 19:03:54 +01:00
ChristianVisintin
b137fecc12 FileSorting and GroupDirs as enums 2020-12-26 18:55:14 +01:00
ChristianVisintin
14125f673a Added options to explorer, in order to define sorting modes and other options. Added bitflags to dependencies; Moved Explorer to Fs module 2020-12-26 17:29:12 +01:00
ChristianVisintin
4911cc5410 Removed issues badge 2020-12-26 15:53:14 +01:00
ChristianVisintin
e0d9ac2ed8 FileTransferActivity::Explorer refactoring; toggle hidden files with <A> 2020-12-26 15:50:57 +01:00
ChristianVisintin
5b042e86ef FsEntry::is_hidden() method 2020-12-26 11:58:28 +01:00
ChristianVisintin
8ccf5eb0bb FsEntry::get_name() returns &str 2020-12-26 10:51:01 +01:00
ChristianVisintin
c0fdc9b8f8 Added new keybindings to help; log new file created 2020-12-26 10:40:45 +01:00
ChristianVisintin
545544ebe2 Added test to config client 2020-12-26 10:33:23 +01:00
ChristianVisintin
644ea1566c Create new file with <N> 2020-12-26 10:16:01 +01:00
ChristianVisintin
f6d1f24b60 cargo clippy 2020-12-26 09:40:24 +01:00
ChristianVisintin
c9a4706c24 Fixed paths 2020-12-25 21:54:10 +01:00
ChristianVisintin
7e275a9075 Docs: private keys with passwords 2020-12-25 21:35:28 +01:00
ChristianVisintin
22a9eb03b6 Added configuration and ssh key storage features to docs 2020-12-25 19:45:49 +01:00
ChristianVisintin
46ee01e073 SetupActivity: <CTRL+E> as <DEL> 2020-12-25 19:38:17 +01:00
ChristianVisintin
16a011e81e Use default protocol also in opt parser 2020-12-25 19:10:28 +01:00
ChristianVisintin
90f28d9f27 SetupActivity ok 2020-12-25 18:39:18 +01:00
ChristianVisintin
2e4ff78124 Refuse empty ssh key 2020-12-25 18:22:12 +01:00
ChristianVisintin
9e66207bf7 SetupActivity layout 2020-12-25 18:20:30 +01:00
ChristianVisintin
226ad8cc50 Show CTRL+C to enter setup in auth activity 2020-12-25 17:25:35 +01:00
ChristianVisintin
00731d67d2 ToString for protocol in AuthActivity 2020-12-25 16:44:01 +01:00
ChristianVisintin
e354d17c70 SetupActivity logic 2020-12-25 16:41:49 +01:00
ChristianVisintin
264b5afad6 Typo in system 2020-12-25 11:43:32 +01:00
ChristianVisintin
76c4f1b67f Close popups also with <ENTER> 2020-12-25 10:00:24 +01:00
ChristianVisintin
96a395615b FileTransferActivity: load ConfigClient; set text editor to configuration's value 2020-12-24 19:01:42 +01:00
ChristianVisintin
82d8bd0342 Read default protocol in auth activity 2020-12-24 18:47:00 +01:00
ChristianVisintin
39e8d1f704 AuthActivity: enter setup with <CTRL+C> 2020-12-24 18:27:38 +01:00
ChristianVisintin
6bf503331e SSH key storage in scp/sftp file transfers 2020-12-24 17:27:57 +01:00
ChristianVisintin
920d3b4af4 get_bookmarks_paths; get_config_paths 2020-12-24 17:19:47 +01:00
ChristianVisintin
f601a0451c fix relative paths codecov 2020-12-24 17:01:11 +01:00
ChristianVisintin
841db02b30 empty method sshkey_storage 2020-12-24 16:57:04 +01:00
ChristianVisintin
1449b5a524 SSH Key Storage 2020-12-24 16:49:02 +01:00
ChristianVisintin
fbb60c7cbc ConfigClient: return key path, not content 2020-12-24 16:42:42 +01:00
ChristianVisintin
f0144e3bc2 ConfigClient 2020-12-24 16:21:53 +01:00
ChristianVisintin
ce924bb294 random utils 2020-12-24 11:23:31 +01:00
ChristianVisintin
797174446c Config module 2020-12-24 11:12:02 +01:00
ChristianVisintin
9b9dc43a7f FileTransferProtocol ToString and FromStr traits 2020-12-24 10:03:48 +01:00
ChristianVisintin
53ee0f618c Updated known issues 2020-12-23 17:49:30 +01:00
ChristianVisintin
c35a5590b5 Updated dependencies 2020-12-22 18:33:38 +01:00
ChristianVisintin
d23fe09f86 fmt 2020-12-22 17:35:07 +01:00
ChristianVisintin
e761a90826 Crypto as utility module 2020-12-22 17:34:52 +01:00
ChristianVisintin
b5abe4538f Replaced sha256 sum with last modification time check, to verify if a file has been changed in the text editor 2020-12-22 17:23:16 +01:00
ChristianVisintin
7202b19d45 Working on 0.3.0 2020-12-22 17:14:44 +01:00
ChristianVisintin
88f9c8910b Fixed download paths 2020-12-21 15:12:47 +01:00
ChristianVisintin
c9948162b9 lol, for some reason cargo-aur renames my crate into termscp-bin. WHY OMG WHY. 2020-12-21 14:42:26 +01:00
ChristianVisintin
4e8e0d1b41 Fixed that f aur action 2020-12-21 14:33:00 +01:00
ChristianVisintin
95b7a773d7 aur runs on 2020-12-21 14:24:25 +01:00
ChristianVisintin
07c924d6a0 0.2.0 2020-12-21 14:21:04 +01:00
ChristianVisintin
50efebdd63 Fixed deploy script 0.2.0 2020-12-21 14:07:43 +01:00
ChristianVisintin
50b8f3cd71 Improved abs paths in host 2020-12-21 13:59:03 +01:00
ChristianVisintin
093dc4f33d Fixed test 2020-12-21 13:43:05 +01:00
ChristianVisintin
963ef88ae5 Arch package (0.2.0) 2020-12-21 12:22:13 +01:00
ChristianVisintin
03e63e11ac 0.2.0 2020-12-21 12:06:44 +01:00
ChristianVisintin
9f478227a9 0.2.0 readme 2020-12-21 12:02:45 +01:00
ChristianVisintin
1d112b3f32 Arch publish workflow and build with docker 2020-12-21 11:48:41 +01:00
ChristianVisintin
1a5bd394b6 Optimizations 2020-12-21 11:12:49 +01:00
ChristianVisintin
3901ed54c6 Copy feature in ui; new keybinding <C> 2020-12-21 11:11:29 +01:00
ChristianVisintin
08728bf55e Copy method (host/transfer) 2020-12-21 10:49:31 +01:00
ChristianVisintin
3220d00b14 archlinux dist 2020-12-21 10:27:33 +01:00
ChristianVisintin
eb12da0308 Utils into multiple files 2020-12-20 15:36:48 +01:00
ChristianVisintin
77545ec87d Text editor documentation 2020-12-20 15:22:52 +01:00
ChristianVisintin
3f6f03af33 Text editor input and session 2020-12-20 15:05:22 +01:00
ChristianVisintin
47f9b39630 Sha256 in utils 2020-12-20 14:49:32 +01:00
ChristianVisintin
9171e0789f Moved recv_file and send_file to an independent method (fix) 2020-12-20 14:47:37 +01:00
ChristianVisintin
5eabaf8ac2 Moved recv_file and send_file to an independent method 2020-12-20 12:33:19 +01:00
ChristianVisintin
b8ad1e7feb edit file method 2020-12-19 21:49:53 +01:00
ChristianVisintin
4d7ea1cdb4 Context methods 2020-12-19 21:47:03 +01:00
ChristianVisintin
021f860ca6 context enter_alternate_screen leave_alternate_screen clear_screen 2020-12-19 21:39:31 +01:00
ChristianVisintin
d0774fd7ed FsEntry::is_file 2020-12-19 21:38:56 +01:00
ChristianVisintin
5632ac6f0b Optimizing log code 2020-12-19 21:12:30 +01:00
ChristianVisintin
7a115e5dc3 wrkdir as member of FileExplorer 2020-12-19 21:10:00 +01:00
ChristianVisintin
d95cda3dfc Optimizing log code 2020-12-19 21:05:56 +01:00
ChristianVisintin
dd9f54acae Working on text-editor 2020-12-19 21:04:55 +01:00
ChristianVisintin
6bc2bcb89e Include set_permissions in UNIX/macos/linux only 2020-12-18 20:58:57 +01:00
ChristianVisintin
9c2e751e11 Rounded borders; collapsed bookmarks borders 2020-12-18 20:54:34 +01:00
ChristianVisintin
2a52a19552 Display transfer speed 2020-12-18 17:06:17 +01:00
ChristianVisintin
1b99d63c47 Added one more space to size in FsEntry::fmt, since sometimes size has 4 digits 2020-12-18 16:42:06 +01:00
ChristianVisintin
33683bc8ce SFTP speed issue marked as solved 2020-12-18 16:16:23 +01:00
ChristianVisintin
61b4a3b76e Log how long it took to download/upload a file 2020-12-18 16:14:23 +01:00
ChristianVisintin
386db6278b Updated github uri 2020-12-18 14:47:48 +01:00
ChristianVisintin
47664b98ad Updated github uri 2020-12-18 14:47:32 +01:00
ChristianVisintin
898b57943b Improved test coverage 2020-12-18 14:40:37 +01:00
ChristianVisintin
d37cc4f796 test name elide in fs fmt 2020-12-18 12:27:37 +01:00
ChristianVisintin
b3fed60d12 Fixed fmt explorer windows 2020-12-18 12:06:54 +01:00
ChristianVisintin
a3d1db3fa2 Fs test fmt 2020-12-18 11:56:08 +01:00
ChristianVisintin
0a79fb3687 Scp: when username was not provided, it didn't fallback to current username 2020-12-18 11:40:51 +01:00
ChristianVisintin
900d9ac3c6 Apply file mode of file downloaded from remote 2020-12-18 11:31:51 +01:00
ChristianVisintin
50b523f9f4 codecov.yml 2020-12-18 10:49:41 +01:00
ChristianVisintin
0759651ee4 codecov 2020-12-18 10:47:37 +01:00
ChristianVisintin
daedee3f66 Documentation about bookmarks 2020-12-16 21:09:59 +01:00
ChristianVisintin
61c58e227e Merge branch 'bookmarks' into 0.2.0 2020-12-16 20:42:47 +01:00
ChristianVisintin
3cd9fc407e Bookmarks layout OK 2020-12-16 20:41:42 +01:00
ChristianVisintin
dd6e2be75d Panic if bookmark name is empty 2020-12-16 20:39:35 +01:00
ChristianVisintin
38e015efe4 removed args from SaveBookmark 2020-12-16 20:34:36 +01:00
ChristianVisintin
335bfc8460 Added 'save password' tab to auth activity when saving bookmarks 2020-12-16 17:01:11 +01:00
ChristianVisintin
344bf8604f Fixed crash in auth_activity 2020-12-16 16:51:21 +01:00
ChristianVisintin
562a1b3ae8 Clippy optimizations 2020-12-16 16:35:11 +01:00
ChristianVisintin
65c541ff2a bookmarks client OK 2020-12-16 16:32:50 +01:00
ChristianVisintin
14ddba022f Implemented BookmarkClient in AuthActivity 2020-12-16 16:01:29 +01:00
ChristianVisintin
10df5abae2 Added Option<String> password to bookmarks 2020-12-16 16:00:43 +01:00
ChristianVisintin
443789698b Copy trait for FileTransferProtocol 2020-12-16 15:57:46 +01:00
ChristianVisintin
52df9bc73b Macro use for magic-crypt 2020-12-16 15:47:02 +01:00
ChristianVisintin
8cb3637954 Magic-crypt 2020-12-16 15:46:46 +01:00
ChristianVisintin
ee55d1fd31 Bookmarks client 2020-12-16 12:09:34 +01:00
ChristianVisintin
dcc289153f Working on bookmarksClient 2020-12-15 22:17:19 +01:00
ChristianVisintin
940d8d94e5 Moving bookmarks from ui to system 2020-12-15 20:48:43 +01:00
ChristianVisintin
c9f1086408 Merge branch 'bookmarks' into 0.2.0 2020-12-15 16:45:34 +01:00
ChristianVisintin
ff4f35e5f5 Clippy ok 2020-12-15 16:31:21 +01:00
ChristianVisintin
b865fed7e9 Removed bookmarks from upcoming features (since implemented) 2020-12-15 16:23:09 +01:00
ChristianVisintin
274c5e309b Set password as InputField after loading bookmark 2020-12-15 16:17:56 +01:00
ChristianVisintin
e7d53a7d00 Help page in auth activity 2020-12-15 15:00:21 +01:00
ChristianVisintin
99117e067e Bookmarks layouts 2020-12-15 14:46:59 +01:00
ChristianVisintin
88e27ee8fe New popup handlers 2020-12-15 14:18:16 +01:00
ChristianVisintin
d3fe546264 Callbacks and handler for Bookmarks events 2020-12-15 14:15:35 +01:00
ChristianVisintin
982b3ec8d0 Delete recents/bookmarks 2020-12-15 14:03:50 +01:00
ChristianVisintin
b8cf2bea77 load bookmarks 2020-12-15 13:55:41 +01:00
ChristianVisintin
a511cd4ac3 Read one input event at a time 2020-12-15 13:44:51 +01:00
ChristianVisintin
0bab9a77a2 Working on bookmarks 2020-12-15 12:28:06 +01:00
ChristianVisintin
1f9b616de7 Bookmarks in auth activity (logic) 2020-12-15 12:22:47 +01:00
ChristianVisintin
71593e3ea7 PartialEq for bookmark 2020-12-15 12:18:35 +01:00
ChristianVisintin
db7ee624e3 Bookmark default 2020-12-15 12:03:31 +01:00
ChristianVisintin
0e7527fb3f file_exists as pub 2020-12-15 11:36:07 +01:00
ChristianVisintin
50fcba63c4 Bookmarks serializer and data types 2020-12-15 11:13:29 +01:00
ChristianVisintin
8046f82214 AuthActivity refactoring 2020-12-15 09:21:52 +01:00
ChristianVisintin
c33045b4b8 Refactoring auth activity... 2020-12-15 09:14:08 +01:00
ChristianVisintin
bb2a60e776 Updated gif 2020-12-14 19:26:16 +01:00
ChristianVisintin
04e2dc86e8 TermSCP 0.1.3 2020-12-14 19:11:42 +01:00
ChristianVisintin
ad44f020a5 Highlight selected entry in tabs, only when the tab is active 2020-12-14 15:52:01 +01:00
ChristianVisintin
ad339aa959 Added missing help entry 2020-12-14 15:47:41 +01:00
ChristianVisintin
3ea8188805 Docker build and deploy 2020-12-14 15:35:41 +01:00
ChristianVisintin
18aacef89f Bookmarks in upcoming features 2020-12-14 13:54:42 +01:00
ChristianVisintin
b8b29371ca Reload directory content with '<L>' 2020-12-14 13:52:49 +01:00
ChristianVisintin
bd99a0c2b2 align auth popup error to center 2020-12-14 13:39:47 +01:00
ChristianVisintin
c493ba45a8 align_text_center as part of utils 2020-12-14 13:33:24 +01:00
ChristianVisintin
e9d5093a52 Read buffer is now 65536 bytes long 2020-12-14 10:03:20 +01:00
ChristianVisintin
7aa8a892f8 Cargo toml 0.1.3 2020-12-14 09:14:58 +01:00
ChristianVisintin
99a3b64833 Explorer tabs have now 70% of layout height, while logging area is 30% 2020-12-14 09:14:23 +01:00
ChristianVisintin
d94c331569 Fixed keybindings readme 2020-12-14 09:03:57 +01:00
ChristianVisintin
e2e25e4ac1 Fixed memory vulnerability in Windows version 2020-12-14 09:03:40 +01:00
ChristianVisintin
2b8be4d1e9 Fixed color mismatch in local explorer tab (Yellow/LightYellow) 2020-12-14 09:01:35 +01:00
ChristianVisintin
db38a53172 Filemode explorer.gif 2020-12-14 08:55:43 +01:00
ChristianVisintin
e273192f19 Working on 0.2.0 2020-12-13 10:27:21 +01:00
79 changed files with 14012 additions and 2513 deletions

21
.github/workflows/aur-pub.yml vendored Normal file
View File

@@ -0,0 +1,21 @@
name: aur-pub
on:
push:
tags:
- "*"
jobs:
aur-publish:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Publish AUR package
uses: KSXGitHub/github-actions-deploy-aur@v2.2.3
with:
pkgname: termscp
pkgbuild: ./dist/pkgs/arch/PKGBUILD
commit_username: ${{ secrets.AUR_USERNAME }}
commit_email: ${{ secrets.AUR_EMAIL }}
ssh_private_key: ${{ secrets.AUR_KEY }}
commit_message: Update AUR package
ssh_keyscan_types: rsa,dsa,ecdsa,ed25519

View File

@@ -7,14 +7,28 @@ env:
jobs:
build:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Clippy
run: cargo clippy
- uses: actions/checkout@v2
- uses: actions-rs/toolchain@v1
with:
toolchain: nightly
override: true
components: rustfmt, clippy
- uses: actions-rs/cargo@v1
with:
command: test
args: --all-features --no-fail-fast
env:
CARGO_INCREMENTAL: "0"
RUSTFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests"
RUSTDOCFLAGS: "-Zprofile -Ccodegen-units=1 -Cinline-threshold=0 -Clink-dead-code -Coverflow-checks=off -Cpanic=abort -Zpanic_abort_tests"
- name: Clippy
run: cargo clippy
- name: Coverage with grcov
uses: actions-rs/grcov@v0.1
- name: Upload to codecov.io
uses: codecov/codecov-action@v1
with:
token: ${{ secrets.CODECOV_TOKEN }}

View File

@@ -7,14 +7,13 @@ env:
jobs:
build:
runs-on: macos-latest
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Clippy
run: cargo clippy
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose -- --test-threads 1
- name: Clippy
run: cargo clippy

View File

@@ -7,14 +7,13 @@ env:
jobs:
build:
runs-on: windows-2019
steps:
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose
- name: Clippy
run: cargo clippy
- uses: actions/checkout@v2
- name: Build
run: cargo build --verbose
- name: Run tests
run: cargo test --verbose -- --test-threads 1
- name: Clippy
run: cargo clippy

8
.gitignore vendored
View File

@@ -13,3 +13,11 @@
**/*.rs.bk
# End of https://www.gitignore.io/api/rust
# Distributions
*.rpm
*.deb
dist/pkgs/arch/*.tar.gz
# Macos
.DS_Store

View File

@@ -1,12 +1,166 @@
# Changelog
- [Changelog](#changelog)
- [0.3.3](#033)
- [0.3.2](#032)
- [0.3.1](#031)
- [0.3.0](#030)
- [0.2.0](#020)
- [0.1.3](#013)
- [0.1.2](#012)
- [0.1.1](#011)
- [0.1.0](#010)
---
## 0.3.3
Released on 28/02/2021
- **Format key attributes**:
- Added `EXTRA` and `LENGTH` parameters to format keys.
- Now keys are provided with this syntax `{KEY_NAME[:LEN[:EXTRA]}`
- **Check for updates**:
- TermSCP will now check for updates on startup and will show in the main page if there is a new version available
- This feature may be disabled from setup (Check for updates => No)
- Enhancements:
- Default choice for deleting file set to "NO" (way too easy to delete files by mistake)
- Added CLI options to set starting workind directory on both local and remote hosts
- Parse remote host now uses a Regex to gather parts (increased stability).
- Now bookmarks and recents are sorted in the UI (bookmarks are sorted by name; recents are sorted by connection datetime)
- Improved stability
## 0.3.2
Released on 24/01/2021
- **Explorer Formatter**:
- Added possibility to customize the format when listing files in the explorers (Read more on README)
- Added `file_fmt` key to configuration (if missing, default will be used).
- Added the text input to the Settings view to set the value for `file_fmt`.
- Bugfix:
- Solved file index in explorer files at start of termscp, in case the first entry is an hidden file
- SCP File transfer: when listing directory entries, check if a symlink points to a directory or to a file
- Dependencies:
- updated `crossterm` to `0.19.0`
- updated `rand` to `0.8.2`
- updated `rpassword` to `5.0.1`
- updated `serde` to `1.0.121`
- updated `tui` to `0.14.0`
- updated `whoami` to `1.1.0`
## 0.3.1
Released on 18/01/2021
- **Keyring to store secrets**
- On both MacOS and Windows, the secret used to encrypt passwords in bookmarks it is now store in the OS secret vault. This provides much more security to store the password
- Enhancements:
- Added connection timeout to 30 seconds to SFTP/SCP clients and improved name lookup system.
- Bugfix:
- Solved index in explorer files list which was no more kept after 0.3.0
- SCP file transfer: fixed possible wrong file size when sending file, due to a possible incoherent size between the file explorer and the actual file size.
- Breaking changes: on **MacOS / Windows systems only**, the password you saved for bookmarks won't be working anymore if you have support for the keyring crate. Because of the migration to keyring, the previously used secret hasn't been migrated to the storage, instead a new secret will be used. To solve this, just save the bookmark again with the password.
## 0.3.0
Released on 10/01/2021
> The SSH Key Storage Update
- **SSH Key Storage**
- Added the possibility to store SSH private keys to access to remote hosts; this feature is supported in both SFTP and SCP.
- SSH Keys can be manipulated through the new **Setup Interface**
- **Setup Interface**
- Added a new area in the interface, where is possible to customize termscp. Access to this interface is achieved pressing `<CTRL+C>` from the home page (`AuthActivity`).
- **Configuration**:
- Added configuration; configuration is stored at
- Linux: `/home/alice/.config/termscp/config.toml`
- MacOS: `/Users/Alice/Library/Application Support/termscp/config.toml`
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\config.toml`
- Added Text editor to configuration
- Added Default File transfer protocol to configuration
- Added "Show hidden files" to configuration
- Added "Group directories" to configuration
- Added SSH keys to configuration; SSH keys will be stored at
- Linux: `/home/alice/.config/termscp/.ssh/`
- MacOS: `/Users/Alice/Library/Application Support/termscp/.ssh/`
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\.ssh\`
- Enhancements:
- Replaced `sha256` sum with last modification time check, to verify if a file has been changed in the text editor
- **FTP**
- Added `LIST` command parser for Windows server (DOS-like syntax)
- Default protocol changed to default protocol in configuration when providing address as CLI argument
- Explorers:
- Hidden files are now not shown by default; use `A` to show hidden files.
- Append `/` to directories name.
- Keybindings:
- `A`: Toggle hidden files
- `B`: Sort files by (name, size, creation time, modify time)
- `N`: New file
- Bugfix:
- SCP client didn't show file types for files
- FTP client didn't show file types for files
- FTP file transfer not working properly with `STOR` and `RETR`.
- Fixed `0 B/S` transfer rate displayed after completing download in less than 1 second
- Dependencies:
- added `bitflags 1.2.1`
- removed `data-encoding`
- updated `ftp` to `4.0.2`
- updated `rand` to `0.8.0`
- removed `ring`
- updated `textwrap` to `0.13.1`
- updated `toml` to `0.5.8`
- updated `whoami` to `1.0.1`
## 0.2.0
Released on 21/12/2020
> The Bookmarks Update
- **Bookmarks**
- Bookmarks and recent connections are now displayed in the home page
- Bookmarks are saved at
- Linux: `/home/alice/.config/termscp/bookmarks.toml`
- MacOS: `/Users/Alice/Library/Application Support/termscp/bookmarks.toml`
- Windows: `C:\Users\Alice\AppData\Roaming\termscp\bookmarks.toml`
- **Text Editor**
- Added text editor feature to explorer view
- Added `o` to keybindings to open a text file
- Keybindings:
- `C`: Copy file/directory
- `O`: Open text file in editor
- Enhancements:
- User interface
- Collpased borders to make everything more *aesthetic*
- Rounded input field boards
- File explorer:
- Log how long it took to upload/download a file and the transfer speed
- Display in progress bar the transfer speed (bytes/seconds)
- Bugfix:
- File mode of file on remote is now reported on local file after being downloaded (unix, linux, macos only)
- Scp: when username was not provided, it didn't fallback to current username
- Explorer: fixed UID format in Windows
## 0.1.3
Released on 14/12/2020
- Enhancements:
- File transfer:
- Read buffer is now 65536 bytes long
- File explorer:
- Fixed color mismatch in local explorer
- Explorer tabs have now 70% of layout height, while logging area is 30%
- Highlight selected entry in tabs, only when the tab is active
- Auth page:
- align popup text to center
- Keybindings:
- `L`: Refresh directory content
- Bugfix:
- Fixed memory vulnerability in Windows version
## 0.1.2
Released on 13/12/2020

View File

@@ -1,7 +1,7 @@
# Contributing
Before contributing to this repository, please first discuss the change you wish to make via issue of this repository before making a change.
Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
Please note we have a [code of conduct](CODE_OF_CONDUCT.md), please follow it in all your interactions with the project.
- [Contributing](#contributing)
- [Preferred contributions](#preferred-contributions)
@@ -9,6 +9,7 @@ Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it
- [Developer contributions guide](#developer-contributions-guide)
- [How TermSCP works](#how-termscp-works)
- [Activities](#activities)
- [Tests fails due to receivers](#tests-fails-due-to-receivers)
- [Implementing File Transfers](#implementing-file-transfers)
---
@@ -17,9 +18,8 @@ Please note we have a [code of conduct](./CODE_OF_CONDUCT.md), please follow it
At the moment, these kind of contributions are more appreciated and should be preferred:
- Fix for issues described in [Known Issues](./README.md#known-issues) or [issues reported by the community](https://github.com/ChristianVisintin/TermSCP/issues)
- Fix for issues described in [Known Issues](./README.md#known-issues) or [issues reported by the community](https://github.com/veeso/termscp/issues)
- New file transfers: for further details see [Implementing File Transfer](#implementing-file-transfers)
- Improvements to translators: any improvement to transliteration is accepted if makes sense, consider that my implementations could be not 100% correct (and probably they're not), indeed consider that I don't speak all these languages (tbh I only can speak Russian as a language with a different alphabet from latin - and I can't even speak it very well).
- Code optimizations: any optimization to the code is welcome
For any other kind of contribution, especially for new features, please submit an issue first.
@@ -30,9 +30,9 @@ Let's make it simple and clear:
1. Open an issue with an **appropriate label** (e.g. bug, enhancement, ...).
2. Write a **properly documentation** compliant with **rustdoc** standard.
3. Write tests for your code. This doesn't apply necessarily for implementation regarding the user-interface module (`ui`).
3. Write tests for your code. This doesn't apply necessarily for implementation regarding the user-interface module (`ui`) and (if a test server is not available) for file transfers.
4. Report changes to the issue you opened, writing a report of what you changed and what you have introduced.
5. Update the `CHANGELOG.md` file with details of changes to the application.
5. Update the `CHANGELOG.md` file with details of changes to the application. In changelog report changes under a chapter called `PR{PULL_REQUEST_NUMBER}` (e.g. PR12).
6. Request maintainers to merge your changes.
## Developer contributions guide
@@ -66,6 +66,12 @@ This trait provides only 3 methods:
---
### Tests fails due to receivers
Yes. This happens quite often and is related to the fact that I'm using public SSH/SFTP/FTP server to test file receivers and sometimes this server go down for even a day or more. If your tests don't pass due to this, don't worry, submit the pull request and I'll take care of testing them by myself.
---
### Implementing File Transfers
This chapter describes how to implement a file transfer in TermSCP. A file transfer is a module which implements the `FileTransfer` trait. The file transfer provides different modules to interact with a remote server, which in addition to the most obvious methods, used to download and upload files, provides also methods to list files, delete files, create directories etc.

1035
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,43 +1,51 @@
[package]
name = "termscp"
version = "0.1.2"
version = "0.3.3"
authors = ["Christian Visintin"]
edition = "2018"
license = "GPL-3.0"
keywords = ["scp-client", "sftp-client", "ftp-client", "winscp", "command-line-utility"]
categories = ["command-line-utilities"]
description = "TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal."
homepage = "https://github.com/ChristianVisintin/TermSCP"
repository = "https://github.com/ChristianVisintin/TermSCP"
homepage = "https://github.com/veeso/termscp"
repository = "https://github.com/veeso/termscp"
documentation = "https://docs.rs/termscp"
readme = "README.md"
include = ["src/**/*", "LICENSE", "README.md", "CHANGELOG.md"]
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
crossterm = "0.18.2"
ftp4 = { version = "^4.0.1", features = ["secure"] }
getopts = "0.2.21"
ssh2 = "0.9.0"
tui = { version = "0.13.0", features = ["crossterm"], default-features = false }
whoami = "1.0.0"
rpassword = "5.0.0"
unicode-width = "0.1.7"
chrono = "0.4.19"
bitflags = "1.2.1"
bytesize = "1.0.1"
textwrap = "0.13.0"
regex = "1.4.2"
lazy_static = "1.4.0"
chrono = "0.4.19"
content_inspector = "0.2.4"
crossterm = "0.19.0"
dirs = "3.0.1"
edit = "0.1.2"
ftp4 = { version = "^4.0.2", features = ["secure"] }
getopts = "0.2.21"
hostname = "0.3.1"
lazy_static = "1.4.0"
magic-crypt = "3.1.6"
rand = "0.8.2"
regex = "1.4.2"
rpassword = "5.0.1"
serde = { version = "1.0.121", features = ["derive"] }
ssh2 = "0.9.0"
tempfile = "3.1.0"
textwrap = "0.13.1"
toml = "0.5.8"
tui = { version = "0.14.0", features = ["crossterm"], default-features = false }
unicode-width = "0.1.7"
ureq = { version = "2.0.2", features = ["json"] }
whoami = "1.1.0"
[target.'cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))'.dependencies]
users = "0.11.0"
[dev-dependencies]
tempfile = "3"
#[patch.crates-io]
#ftp = { git = "https://github.com/ChristianVisintin/rust-ftp" }
[target.'cfg(any(target_os = "windows", target_os = "macos"))'.dependencies]
keyring = "0.10.1"
[[bin]]
name = "termscp"

View File

@@ -632,7 +632,7 @@ state the exclusion of warranty; and each file should have at least
the "copyright" line and a pointer to where the full notice is found.
TermSCP
Copyright (C) 2020 Christian Visintin
Copyright (C) 2020-2021 Christian Visintin
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
@@ -652,7 +652,7 @@ Also add information on how to contact you by electronic and paper mail.
If the program does terminal interaction, make it output a short
notice like this when it starts in an interactive mode:
TermSCP Copyright (C) 2020 Christian Visintin
TermSCP Copyright (C) 2020-2021 Christian Visintin
This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
This is free software, and you are welcome to redistribute it
under certain conditions; type `show c' for details.

272
README.md
View File

@@ -1,12 +1,12 @@
# TermSCP
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Stars](https://img.shields.io/github/stars/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP) [![Issues](https://img.shields.io/github/issues/ChristianVisintin/TermSCP.svg)](https://github.com/ChristianVisintin/TermSCP/issues) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.1.2-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp)
[![License: GPL v3](https://img.shields.io/badge/License-GPLv3-blue.svg)](https://www.gnu.org/licenses/gpl-3.0) [![Stars](https://img.shields.io/github/stars/veeso/termscp.svg)](https://github.com/veeso/termscp) [![Downloads](https://img.shields.io/crates/d/termscp.svg)](https://crates.io/crates/termscp) [![Crates.io](https://img.shields.io/badge/crates.io-v0.3.3-orange.svg)](https://crates.io/crates/termscp) [![Docs](https://docs.rs/termscp/badge.svg)](https://docs.rs/termscp)
[![Build](https://github.com/ChristianVisintin/TermSCP/workflows/Linux/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions) [![Build](https://github.com/ChristianVisintin/TermSCP/workflows/MacOS/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions) [![Build](https://github.com/ChristianVisintin/TermSCP/workflows/Windows/badge.svg)](https://github.com/ChristianVisintin/TermSCP/actions)
[![Build](https://github.com/veeso/termscp/workflows/Linux/badge.svg)](https://github.com/veeso/termscp/actions) [![Build](https://github.com/veeso/termscp/workflows/MacOS/badge.svg)](https://github.com/veeso/termscp/actions) [![Build](https://github.com/veeso/termscp/workflows/Windows/badge.svg)](https://github.com/veeso/termscp/actions) [![codecov](https://codecov.io/gh/veeso/termscp/branch/main/graph/badge.svg?token=au67l7nQah)](https://codecov.io/gh/veeso/termscp)
~ Basically, WinSCP on a terminal ~
Developed by Christian Visintin
Current version: 0.1.2 (13/12/2020)
Current version: 0.3.3 (28/02/2021)
---
@@ -18,11 +18,19 @@ Current version: 0.1.2 (13/12/2020)
- [Cargo 🦀](#cargo-)
- [Deb package 📦](#deb-package-)
- [RPM package 📦](#rpm-package-)
- [AUR Package 🔼](#aur-package-)
- [Chocolatey 🍫](#chocolatey-)
- [Brew 🍻](#brew-)
- [Usage ❓](#usage-)
- [Address argument](#address-argument)
- [How Password can be provided](#how-password-can-be-provided)
- [Address argument 🌎](#address-argument-)
- [How Password can be provided 🔐](#how-password-can-be-provided-)
- [Bookmarks ⭐](#bookmarks-)
- [Are my passwords Safe 😈](#are-my-passwords-safe-)
- [Text Editor ✏](#text-editor-)
- [How do I configure the text editor 🦥](#how-do-i-configure-the-text-editor-)
- [Configuration ⚙️](#configuration-)
- [SSH Key Storage 🔐](#ssh-key-storage-)
- [File Explorer Format](#file-explorer-format)
- [Keybindings ⌨](#keybindings-)
- [Documentation 📚](#documentation-)
- [Known issues 🧻](#known-issues-)
@@ -45,18 +53,27 @@ TermSCP is basically a porting of WinSCP to terminal. So basically is a terminal
### Why TermSCP 🤔
It happens quite often to me, when using SCP at work to forget the path of a file on a remote machine, which forces me then to connect through SSH, gather the file path and finally download it through SCP. I could use WinSCP, but I use Linux and I pratically use the terminal for everything, so I wanted something like WinSCP on my terminal. Yeah, I know there midnight commander too, but actually I don't like it very much tbh (and hasn't a decent support for scp).
It happens quite often to me, when using SCP at work to forget the path of a file on a remote machine, which forces me to connect through SSH, gather the file path and finally download it through SCP. I could use WinSCP, but I use Linux and I pratically use the terminal for everything, so I wanted something like WinSCP on my terminal. Yeah, I know there is midnight commander too, but actually I don't like it very much tbh (and hasn't a decent support for scp).
## Features 🎁
- Different communication protocols
- Different communication protocols support
- SFTP
- SCP
- FTP and FTPS
- Practical user interface to explore and operate on the remote and on the local machine file system
- Compatible with Windows, Linux, BSD and MacOS
- Handy user interface to explore and operate on the remote and on the local machine file system
- Bookmarks and recent connections can be saved to access quickly to your favourite hosts
- Supports text editors to view and edit text files
- Supports both SFTP/SCP authentication through SSH keys and username/password
- Customizations:
- Custom file explorer format
- Customizable text editor
- Customizable file sorting
- SSH key storage
- Written in Rust
- Easy to extend with new file transfers protocols
- Developed keeping an eye on performance
---
@@ -72,10 +89,17 @@ If you want to contribute to this project, don't forget to check out our contrib
cargo install termscp
```
Requirements:
- Linux
- pkg-config
- libssh2
- openssl
### Deb package 📦
Get `deb` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.2_amd64.deb)
or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp_0.1.2_amd64.deb`
Get `deb` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp_0.3.3_amd64.deb)
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp_0.3.3_amd64.deb`
then install through dpkg:
@@ -87,8 +111,8 @@ gdebi termscp_*.deb
### RPM package 📦
Get `rpm` package from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.2-1.x86_64.rpm)
or run `wget https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp-0.1.2-1.x86_64.rpm`
Get `rpm` package from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp-0.3.3-1.x86_64.rpm)
or run `wget https://github.com/veeso/termscp/releases/latest/download/termscp-0.3.3-1.x86_64.rpm`
then install through rpm:
@@ -96,6 +120,14 @@ then install through rpm:
rpm -U termscp_*.rpm
```
### AUR Package 🔼
On Arch Linux based distribution, you can install termscp using for istance [yay](https://github.com/Jguer/yay), which I recommend to install AUR packages.
```sh
yay -S termscp
```
### Chocolatey 🍫
You can install TermSCP on Windows using [chocolatey](https://chocolatey.org/)
@@ -106,7 +138,7 @@ Start PowerShell as administrator and run
choco install termscp
```
Alternatively you can download the ZIP file from [HERE](https://github.com/ChristianVisintin/TermSCP/releases/download/latest/termscp.0.1.2.nupkg)
Alternatively you can download the ZIP file from [HERE](https://github.com/veeso/termscp/releases/latest/download/termscp.0.3.3.nupkg)
and then with PowerShell started with administrator previleges, run:
@@ -121,7 +153,7 @@ You can install TermSCP on MacOS using [brew](https://brew.sh/)
From your terminal run
```sh
brew tap ChristianVisintin/termscp
brew tap veeso/termscp
brew install termscp
```
@@ -131,6 +163,8 @@ brew install termscp
TermSCP can be started with the following options:
`termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]`
- `-P, --password <password>` if address is provided, password will be this argument
- `-v, --version` Print version info
- `-h, --help` Print help page
@@ -139,23 +173,25 @@ TermSCP can be started in two different mode, if no extra arguments is provided,
Alternatively, the user can provide an address as argument to skip the authentication form and starting directly the connection to the remote server.
### Address argument
If address argument is provided you can also provide the start working directory for local host
### Address argument 🌎
The address argument has the following syntax:
```txt
[protocol]://[username@]<address>[:port]
[protocol://][username@]<address>[:port][:wrkdir]
```
Let's see some example of this particular syntax, since it's very comfortable and you'll probably going to use this instead of the other one...
- Connect using default protocol (sftp) to 192.168.1.31, port is default for this protocol (22); username is current user's name
- Connect using default protocol (*defined in configuration*) to 192.168.1.31, port if not provided is default for the selected protocol (in this case depends on your configuration); username is current user's name
```sh
termscp 192.168.1.31
```
- Connect using default protocol (sftp) to 192.168.1.31, port is default for this protocol (22); username is `root`
- Connect using default protocol (*defined in configuration*) to 192.168.1.31; username is `root`
```sh
termscp root@192.168.1.31
@@ -167,7 +203,13 @@ Let's see some example of this particular syntax, since it's very comfortable an
termscp scp://omar@192.168.1.31:4022
```
#### How Password can be provided
- Connect using scp to 192.168.1.31, port is 4022; username is `omar`. You will start in directory `/tmp`
```sh
termscp scp://omar@192.168.1.31:4022:/tmp
```
#### How Password can be provided 🔐
You have probably noticed, that, when providing the address as argument, there's no way to provide the password.
Password can be basically provided through 3 ways when address argument is provided:
@@ -178,31 +220,147 @@ Password can be basically provided through 3 ways when address argument is provi
---
## Bookmarks ⭐
In TermSCP it is possible to save favourites hosts, which can be then loaded quickly from the main layout of termscp.
TermSCP will also save the last 16 hosts you connected to.
This feature allows you to load all the parameters required to connect to a certain remote, simply selecting the bookmark in the tab under the authentication form.
Bookmarks will be saved, if possible at:
- `$HOME/.config/termscp/` on Linux/BSD
- `$HOME/Library/Application Support/termscp` on MacOs
- `FOLDERID_RoamingAppData\termscp\` on Windows
For bookmarks only (this won't apply to recent hosts) it is also possible to save the password used to authenticate. The password is not saved by default and must be specified through the prompt when saving a new Bookmark.
> I was very undecided about storing passwords in termscp. The reason? Saving a password on your computer might give access to a hacker to any server you've registered. But I must admit by myself that for many machines typing the password everytime is really boring, also many times I have to work with machines in LAN, which wouldn't provide any advantage to an attacker, So I came out with a good compromise for passwords.
I warmly suggest you to follow these guidelines in order to decide whether you should or you shouldn't save passwords:
- **DON'T** save passwords for machines which are exposed on the internet, save passwords only for machines in LAN
- Make sure your machine is protected by attackers. If possible encrypt your disk and don't leave your PC unlocked while you're away.
- Preferably, save passwords only when a compromising of the target machine wouldn't be a problem.
To create a bookmark, just fulfill the authentication form and then input `<CTRL+S>`; you'll then be asked to give a name to your bookmark, and tadah, the bookmark has been created.
If you go to [gallery](#gallery-), there is a GIF showing how bookmarks work 💪.
### Are my passwords Safe 😈
Well, kinda.
As said before, bookmarks are saved in your configuration directory along with passwords. Passwords are obviously not plain text, they are encrypted with **AES-128**. Does this make them safe? Well, depends on your operating system:
On Windows and MacOS the passwords are stored, if possible (but should be), in respectively the Windows Vault and the Keychain. This is actually super-safe and is directly managed by your operating system.
On Linux and BSD, on the other hand, the key used to encrypt your passwords is stored on your drive (at $HOME/.config/termscp). It is then, still possible to retrieve the key to decrypt passwords. Luckily, the location of the key guarantees your key can't be read by users different from yours, but yeah, I still wouldn't save the password for a server exposed on the internet 😉.
Actually [keyring-rs](https://github.com/hwchen/keyring-rs), supports Linux, but for different reasons I preferred not to make it available for this configuration. If you want to read more about my decision read [this issue](https://github.com/veeso/termscp/issues/2), while if you think this might have been implemented differently feel free to open an issue with your proposal.
---
## Text Editor ✏
TermSCP has, as you might have noticed, many features, one of these is the possibility to view and edit text file. It doesn't matter if the file is located on the local host or on the remote host, termscp provides the possibility to open a file in your favourite text editor.
In case the file is located on remote host, the file will be first downloaded into your temporary file directory and then, **only** if changes were made to the file, re-uploaded to the remote host. TermSCP checks if you made changes to the file verifying the last modification time of the file.
Just a reminder: **you can edit only textual file**; binary files are not supported.
### How do I configure the text editor 🦥
Text editor is automatically found using this [awesome crate](https://github.com/milkey-mouse/edit), if you want to change the text editor to use, change it in termscp configuration. [View more](#configuration-)
---
## Configuration ⚙️
TermSCP supports some user defined parameters, which can be defined in the configuration.
Underhood termscp has a TOML file and some other directories where all the parameters will be saved, but don't worry, you won't touch any of these files, since I made possible to configure termscp from its user interface entirely.
termscp, like for bookmarks, just requires to have these paths accessible:
- `$HOME/.config/termscp/` on Linux/BSD
- `$HOME/Library/Application Support/termscp` on MacOs
- `FOLDERID_RoamingAppData\termscp\` on Windows
To access configuration, you just have to press `<CTRL+C>` from the home of termscp.
These parameters can be changed:
- **Default Protocol**: the default protocol is the default value for the file transfer protocol to be used in termscp. This applies for the login page and for the address CLI argument.
- **Text Editor**: the text editor to use. By default termscp will find the default editor for you; with this option you can force an editor to be used (e.g. `vim`). **Also GUI editors are supported**, unless they `nohup` from the parent process so if you ask: yes, you can use `notepad.exe`, and no: **Visual Studio Code doesn't work**.
- **Show Hidden Files**: select whether hidden files shall be displayed by default. You will be able to decide whether to show or not hidden files at runtime pressing `A` anyway.
- **Check for updates**: if set to `yes`, termscp will fetch the Github API to check if there is a new version of termscp available.
- **Group Dirs**: select whether directories should be groupped or not in file explorers. If `Display first` is selected, directories will be sorted using the configured method but displayed before files, viceversa if `Display last` is selected.
### SSH Key Storage 🔐
Along with configuration, termscp provides also an **essential** feature for **SFTP/SCP clients**: the SSH key storage.
You can access the SSH key storage, from configuration moving to the `SSH Keys` tab, once there you can:
- **Add a new key**: just press `<CTRL+N>` and you will be prompted to create a new key. Provide the hostname/ip address and the username associated to the key and finally a text editor will open up: paste the **PRIVATE** ssh key into the text editor, save and quit.
- **Remove an existing key**: just press `<DEL>` or `<CTRL+E>` on the key you want to remove, to delete persistently the key from termscp.
- **Edit an existing key**: just press `<ENTER>` on the key you want to edit, to change the private key.
> Q: Wait, my private key is protected with password, can I use it?
> A: Of course you can. The password provided for authentication in termscp, is valid both for username/password authentication and for RSA key authentication.
### File Explorer Format
It is possible through configuration to define a custom format for the file explorer. This field, with name `File formatter syntax` will define how the file entries will be displayed in the file explorer.
The syntax for the formatter is the following `{KEY1}... {KEY2:LENGTH}... {KEY3:LENGTH:EXTRA} {KEYn}...`.
Each key in bracket will be replaced with the related attribute, while everything outside brackets will be left unchanged.
- The key name is mandatory and must be one of the keys below
- The length describes the length reserved to display the field. Static attributes doesn't support this (GROUP, PEX, SIZE, USER)
- Extra is supported only by some parameters and is an additional options. See keys to check if extra is supported.
These are the keys supported by the formatter:
- `ATIME`: Last access time (with default syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{ATIME:8:%H:%M}`)
- `CTIME`: Creation time (with syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{CTIME:8:%H:%M}`)
- `GROUP`: Owner group
- `MTIME`: Last change time (with syntax `%b %d %Y %H:%M`); Extra might be provided as the time syntax (e.g. `{MTIME:8:%H:%M}`)
- `NAME`: File name (Elided if longer than 24)
- `PEX`: File permissions (UNIX format)
- `SIZE`: File size (omitted for directories)
- `SYMLINK`: Symlink (if any `-> {FILE_PATH}`)
- `USER`: Owner user
If left empty, the default formatter syntax will be used: `{NAME:24} {PEX} {USER} {SIZE} {MTIME:17:%b %d %Y %H:%M}`
---
## Keybindings ⌨
| Key | Command |
|---------------|-------------------------------------------------------|
| `<ESC>` | Disconnect from remote; return to authentication page |
| `<TAB>` | Switch between log tab and explorer |
| `<BACKSPACE>` | Go to previous directory in stack |
| `<RIGHT>` | Move to remote explorer tab |
| `<LEFT>` | Move to local explorer tab |
| `<UP>` | Move up in selected list |
| `<DOWN>` | Move down in selected list |
| `<PGUP>` | Move up in selected list by 8 rows |
| `<PGDOWN>` | Move down in selected list by 8 rows |
| `<ENTER>` | Enter directory |
| `<SPACE>` | Upload / download selected file |
| `<D>` | Make directory |
| `<E>` | Delete file (Same as `CANC`) |
| `<G>` | Go to supplied path |
| `<H>` | Show help |
| `<I>` | Show info about selected file or directory |
| `<Q>` | Quit TermSCP |
| `<R>` | Rename file |
| `<U>` | Go to parent directory |
| `<DEL>` | Delete file |
| `CTRL+C` | Abort file transfer process |
| Key | Command | Reminder |
|---------------|-------------------------------------------------------|-------------|
| `<ESC>` | Disconnect from remote; return to authentication page | |
| `<TAB>` | Switch between log tab and explorer | |
| `<BACKSPACE>` | Go to previous directory in stack | |
| `<RIGHT>` | Move to remote explorer tab | |
| `<LEFT>` | Move to local explorer tab | |
| `<UP>` | Move up in selected list | |
| `<DOWN>` | Move down in selected list | |
| `<PGUP>` | Move up in selected list by 8 rows | |
| `<PGDOWN>` | Move down in selected list by 8 rows | |
| `<ENTER>` | Enter directory | |
| `<SPACE>` | Upload / download selected file | |
| `<A>` | Toggle hidden files | All |
| `<B>` | Sort files by | Bubblesort? |
| `<C>` | Copy file/directory | Copy |
| `<D>` | Make directory | Directory |
| `<E>` | Delete file (Same as `DEL`) | Erase |
| `<G>` | Go to supplied path | Go to |
| `<H>` | Show help | Help |
| `<I>` | Show info about selected file or directory | Info |
| `<L>` | Reload current directory's content | List |
| `<N>` | Create new file with provided name | New |
| `<O>` | Edit file; see [Text editor](#text-editor-) | Open |
| `<Q>` | Quit TermSCP | Quit |
| `<R>` | Rename file | Rename |
| `<U>` | Go to parent directory | Upper |
| `<DEL>` | Delete file | |
| `<CTRL+C>` | Abort file transfer process | |
---
@@ -214,18 +372,17 @@ The developer documentation can be found on Rust Docs at <https://docs.rs/termsc
## Known issues 🧻
- Ftp:
- Time in explorer is `1 Jan 1970`, but shouldn't be: that's because chrono can't parse date in a different locale. So if your server has a locale different from the one on your machine, it won't be able to parse the date.
- Some servers don't work: yes, some kind of ftp server don't work correctly, sometimes it won't display any files in the directories, some other times uploading files will fail. Up to date, `vsftpd` is the only one server which I saw working correctly with TermSCP. Am I going to solve this? I'd like to, but it's not my fault at all. Unfortunately [rust-ftp](https://github.com/mattnenterprise/rust-ftp) is an abandoned project (up to 2020), indeed I had to patch many stuff by myself. I'll try to solve these issues, but it will take a long time.
- Sftp:
- sftp is much slower than scp: Okay this is an annoying issue, and again: not my fault. It seems there is an issue with [ssh2-rs](https://github.com/alexcrichton/ssh2-rs) library. If you want to stay up to date with the status of this issue, subscribe to [this issue](https://github.com/alexcrichton/ssh2-rs/issues/206)
- `NoSuchFileOrDirectory` on connect: let me guess, you're running on WSL and you've installed termscp through cargo. I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`.
- `NoSuchFileOrDirectory` on connect (WSL): I know about this issue and it's a glitch of WSL I guess. Don't worry about it, just move the termscp executable into another PATH location, such as `/usr/bin`, or install it through the appropriate package format (e.g. deb).
---
## Upcoming Features 🧪
- **Text viewer**: possibility to open and read file both on remote and on local host; this will also support syntax highlighting.
- **New commands in file explorer** (0.4.0 - March 2021)
- **Find**: search for files through directories, with built-in regex support
- **Execute**: run a command on both local host and remote host in protocols where this is supported
- SCP for sure
- SFTP: might be a challenge, since I should start a SSH session, but I guess it's not impossible
---
@@ -247,7 +404,10 @@ TermSCP is powered by these aweseome projects:
- [bytesize](https://github.com/hyunsik/bytesize)
- [crossterm](https://github.com/crossterm-rs/crossterm)
- [edit](https://github.com/milkey-mouse/edit)
- [keyring-rs](https://github.com/hwchen/keyring-rs)
- [rpassword](https://github.com/conradkleinespel/rpassword)
- [rust-ftp](https://github.com/mattnenterprise/rust-ftp)
- [ssh2-rs](https://github.com/alexcrichton/ssh2-rs)
- [textwrap](https://github.com/mgeisler/textwrap)
- [tui-rs](https://github.com/fdehau/tui-rs)
@@ -257,8 +417,22 @@ TermSCP is powered by these aweseome projects:
## Gallery 🎬
> Termscp Home
![Auth](assets/images/auth.gif)
> Bookmarks
![Bookmarks](assets/images/bookmarks.gif)
> Setup
![Setup](assets/images/config.gif)
> Text editor
![TextEditor](assets/images/text-editor.gif)
---
## License 📃

Binary file not shown.

Before

Width:  |  Height:  |  Size: 210 KiB

After

Width:  |  Height:  |  Size: 314 KiB

BIN
assets/images/bookmarks.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 292 KiB

BIN
assets/images/config.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 689 KiB

BIN
assets/images/explorer.gif Executable file → Normal file

Binary file not shown.

Before

Width:  |  Height:  |  Size: 632 KiB

After

Width:  |  Height:  |  Size: 635 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

7
codecov.yml Normal file
View File

@@ -0,0 +1,7 @@
ignore:
- src/main.rs
- src/lib.rs
- src/activity_manager.rs
- src/ui/
fixes:
- "/::"

25
dist/build/README.md vendored Normal file
View File

@@ -0,0 +1,25 @@
# Build with Docker
- [Build with Docker](#build-with-docker)
- [Prerequisites](#prerequisites)
- [Build](#build)
---
## Prerequisites
- Docker
## Build
1. Build x86_64
this will build termscp for:
- Linux x86_64 Deb packages
- Linux x86_64 RPM packages
- Windows x86_64 MSVC packages
```sh
```

47
dist/build/deploy.sh vendored Executable file
View File

@@ -0,0 +1,47 @@
#!/bin/bash
if [ -z "$1" ]; then
echo "Usage: deploy.sh <version>"
exit 1
fi
VERSION=$1
set -e # Don't fail
# Create pkgs directory
cd ..
PKGS_DIR=$(pwd)/pkgs
cd -
mkdir -p ${PKGS_DIR}/
# Build x86_64_deb
cd x86_64_debian9/
docker build --tag termscp-${VERSION}-x86_64_debian9 .
cd -
mkdir -p ${PKGS_DIR}/deb/
CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_debian9 termscp-${VERSION}-x86_64_debian9)
docker cp ${CONTAINER_NAME}:/usr/src/termscp/target/debian/termscp_${VERSION}_amd64.deb ${PKGS_DIR}/deb/
# Build x86_64_centos7
cd x86_64_centos7/
docker build --tag termscp-${VERSION}-x86_64_centos7 .
cd -
mkdir -p ${PKGS_DIR}/rpm/
CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_centos7 termscp-${VERSION}-x86_64_centos7)
docker cp ${CONTAINER_NAME}:/usr/src/termscp/target/release/rpmbuild/RPMS/x86_64/termscp-${VERSION}-1.el7.x86_64.rpm ${PKGS_DIR}/rpm/termscp-${VERSION}-1.x86_64.rpm
# Build x86_64_archlinux
##################### TEMP REMOVED ###################################
# cd x86_64_archlinux/
# docker build --tag termscp-${VERSION}-x86_64_archlinux .
# # Create container and get AUR pkg
# cd -
# mkdir -p ${PKGS_DIR}/arch/
# CONTAINER_NAME=$(docker create termscp-${VERSION}-x86_64_archlinux termscp-${VERSION}-x86_64_archlinux)
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/termscp-${VERSION}-x86_64.tar.gz ${PKGS_DIR}/arch/
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/PKGBUILD ${PKGS_DIR}/arch/
# docker cp ${CONTAINER_NAME}:/usr/src/termscp/.SRCINFO ${PKGS_DIR}/arch/
# # Replace termscp-bin with termscp in PKGBUILD
# sed -i 's/termscp-bin/termscp/g' ${PKGS_DIR}/arch/PKGBUILD
##################### TEMP REMOVED ###################################
exit $?

19
dist/build/x86_64/Dockerfile vendored Normal file
View File

@@ -0,0 +1,19 @@
FROM rust:1.48.0 AS builder
WORKDIR /usr/src/
# Add toolchains
RUN rustup target add x86_64-unknown-linux-gnu
# Install dependencies
RUN apt update && apt install -y rpm
# Clone repository
RUN git clone https://github.com/veeso/termscp.git
# Set workdir to termscp
WORKDIR /usr/src/termscp/
# Install cargo RPM/Deb
RUN cargo install cargo-deb cargo-rpm
# Build for x86_64
RUN cargo build --release --target x86_64-unknown-linux-gnu
# Build pkgs
RUN cargo deb && cargo rpm init && cargo rpm build
CMD ["sh"]

33
dist/build/x86_64_archlinux/Dockerfile vendored Normal file
View File

@@ -0,0 +1,33 @@
FROM archlinux:base-20210120.0.13969 as builder
WORKDIR /usr/src/
# Install dependencies
RUN pacman -Syu --noconfirm \
git \
gcc \
openssl \
pkg-config \
sudo
# Install rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
chmod +x /tmp/rust.sh && \
/tmp/rust.sh -y
# Create build user
RUN useradd build -m && \
passwd -d build && \
mkdir -p termscp && \
chown -R build.build termscp/
# Clone repository
RUN git clone https://github.com/veeso/termscp.git
# Set workdir to termscp
WORKDIR /usr/src/termscp/
# Install cargo arxch
RUN source $HOME/.cargo/env && cargo install cargo-aur
# Build for x86_64
RUN source $HOME/.cargo/env && cargo build --release
# Build pkgs
RUN source $HOME/.cargo/env && cargo aur
# Create SRCINFO
RUN chown -R build.build ../termscp/ && sudo -u build bash -c 'makepkg --printsrcinfo > .SRCINFO'
CMD ["sh"]

25
dist/build/x86_64_centos7/Dockerfile vendored Normal file
View File

@@ -0,0 +1,25 @@
FROM centos:centos7 as builder
WORKDIR /usr/src/
# Install dependencies
RUN yum -y install \
git \
gcc \
openssl \
pkgconfig \
openssl-devel
# Install rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
chmod +x /tmp/rust.sh && \
/tmp/rust.sh -y
# Clone repository
RUN git clone https://github.com/veeso/termscp.git
# Set workdir to termscp
WORKDIR /usr/src/termscp/
# Install cargo arxch
RUN source $HOME/.cargo/env && cargo install cargo-rpm
# Build for x86_64
RUN source $HOME/.cargo/env && cargo build --release
# Build pkgs
RUN source $HOME/.cargo/env && yum -y install rpm-build && cargo rpm init && cargo rpm build
CMD ["sh"]

28
dist/build/x86_64_debian8/Dockerfile vendored Normal file
View File

@@ -0,0 +1,28 @@
FROM debian:jessie
WORKDIR /usr/src/
# Install dependencies
RUN apt update && apt install -y \
git \
gcc \
pkg-config \
libssl-dev \
libssh2-1-dev \
curl
# Install rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
chmod +x /tmp/rust.sh && \
/tmp/rust.sh -y
# Clone repository
RUN git clone https://github.com/veeso/termscp.git
# Set workdir to termscp
WORKDIR /usr/src/termscp/
# Install cargo deb
RUN . $HOME/.cargo/env && cargo install cargo-deb
# Build for x86_64
RUN . $HOME/.cargo/env && cargo build --release
# Build pkgs
RUN . $HOME/.cargo/env && cargo deb
CMD ["sh"]

28
dist/build/x86_64_debian9/Dockerfile vendored Normal file
View File

@@ -0,0 +1,28 @@
FROM debian:stretch
WORKDIR /usr/src/
# Install dependencies
RUN apt update && apt install -y \
git \
gcc \
pkg-config \
libssl-dev \
libssh2-1-dev \
curl
# Install rust
RUN curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs > /tmp/rust.sh && \
chmod +x /tmp/rust.sh && \
/tmp/rust.sh -y
# Clone repository
RUN git clone https://github.com/veeso/termscp.git
# Set workdir to termscp
WORKDIR /usr/src/termscp/
# Install cargo deb
RUN . $HOME/.cargo/env && cargo install cargo-deb
# Build for x86_64
RUN . $HOME/.cargo/env && cargo build --release
# Build pkgs
RUN . $HOME/.cargo/env && cargo deb
CMD ["sh"]

14
dist/pkgs/arch/.SRCINFO vendored Normal file
View File

@@ -0,0 +1,14 @@
pkgbase = termscp
pkgdesc = TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal.
pkgver = 0.3.3
pkgrel = 1
url = https://github.com/veeso/termscp
arch = x86_64
license = GPL-3.0
provides = termscp
options = strip
source = https://github.com/veeso/termscp/releases/download/v0.3.3/termscp-0.3.3-x86_64.tar.gz
sha256sums = 7a8c70add8306a2cb3f2ee1d075a00fef143fc9aad4199797c7462bab1649296
pkgname = termscp

16
dist/pkgs/arch/PKGBUILD vendored Normal file
View File

@@ -0,0 +1,16 @@
# Maintainer: Christian Visintin
pkgname=termscp
pkgver=0.3.3
pkgrel=1
pkgdesc="TermSCP is a SCP/SFTP/FTPS client for command line with an integrated UI to explore the remote file system. Basically WinSCP on a terminal."
url="https://github.com/veeso/termscp"
license=("GPL-3.0")
arch=("x86_64")
provides=("termscp")
options=("strip")
source=("https://github.com/veeso/termscp/releases/download/v$pkgver/termscp-$pkgver-x86_64.tar.gz")
sha256sums=("7a8c70add8306a2cb3f2ee1d075a00fef143fc9aad4199797c7462bab1649296")
package() {
install -Dm755 termscp -t "$pkgdir/usr/bin/"
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -27,11 +27,10 @@ use std::path::PathBuf;
// Deps
use crate::filetransfer::FileTransferProtocol;
use crate::host::Localhost;
use crate::host::{HostError, Localhost};
use crate::ui::activities::{
auth_activity::AuthActivity,
filetransfer_activity::FileTransferActivity, filetransfer_activity::FileTransferParams,
Activity,
auth_activity::AuthActivity, filetransfer_activity::FileTransferActivity,
filetransfer_activity::FileTransferParams, setup_activity::SetupActivity, Activity,
};
use crate::ui::context::Context;
@@ -45,6 +44,7 @@ use std::time::Duration;
pub enum NextActivity {
Authentication,
FileTransfer,
SetupActivity,
}
/// ### ActivityManager
@@ -60,14 +60,11 @@ impl ActivityManager {
/// ### new
///
/// Initializes a new Activity Manager
pub fn new(
local_dir: &PathBuf,
interval: Duration,
) -> Result<ActivityManager, ()> {
pub fn new(local_dir: &PathBuf, interval: Duration) -> Result<ActivityManager, HostError> {
// Prepare Context
let host: Localhost = match Localhost::new(local_dir.clone()) {
Ok(h) => h,
Err(_) => return Err(()),
Err(e) => return Err(e),
};
let ctx: Context = Context::new(host);
Ok(ActivityManager {
@@ -87,6 +84,7 @@ impl ActivityManager {
protocol: FileTransferProtocol,
username: Option<String>,
password: Option<String>,
entry_directory: Option<PathBuf>,
) {
self.ftparams = Some(FileTransferParams {
address,
@@ -94,6 +92,7 @@ impl ActivityManager {
protocol,
username,
password,
entry_directory,
});
}
@@ -109,6 +108,7 @@ impl ActivityManager {
Some(activity) => match activity {
NextActivity::Authentication => self.run_authentication(),
NextActivity::FileTransfer => self.run_filetransfer(),
NextActivity::SetupActivity => self.run_setup(),
},
None => break, // Exit
}
@@ -126,13 +126,13 @@ impl ActivityManager {
/// Returns the next activity to run
fn run_authentication(&mut self) -> Option<NextActivity> {
// Prepare activity
let mut activity: AuthActivity = AuthActivity::new();
let mut activity: AuthActivity = AuthActivity::default();
// Prepare result
let result: Option<NextActivity>;
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None
None => return None,
};
// Create activity
activity.on_create(ctx);
@@ -145,6 +145,11 @@ impl ActivityManager {
result = None;
break;
}
if activity.setup {
// User requested activity
result = Some(NextActivity::SetupActivity);
break;
}
if activity.submit {
// User submitted, set next activity
result = Some(NextActivity::FileTransfer);
@@ -160,7 +165,8 @@ impl ActivityManager {
0 => None,
_ => Some(activity.password.clone()),
},
protocol: activity.protocol.clone(),
protocol: activity.protocol,
entry_directory: None, // Has use only when accessing with address
});
break;
}
@@ -189,7 +195,7 @@ impl ActivityManager {
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None
None => return None,
};
// Create activity
activity.on_create(ctx);
@@ -214,4 +220,35 @@ impl ActivityManager {
self.context = activity.on_destroy();
result
}
/// ### run_setup
///
/// `SetupActivity` run loop.
/// Returns when activity terminates.
/// Returns the next activity to run
fn run_setup(&mut self) -> Option<NextActivity> {
// Prepare activity
let mut activity: SetupActivity = SetupActivity::default();
// Get context
let ctx: Context = match self.context.take() {
Some(ctx) => ctx,
None => return None,
};
// Create activity
activity.on_create(ctx);
loop {
// Draw activity
activity.on_draw();
// Check if activity has terminated
if activity.quit {
break;
}
// Sleep for ticks
sleep(self.interval);
}
// Destroy activity
self.context = activity.on_destroy();
// This activity always returns to AuthActivity
Some(NextActivity::Authentication)
}
}

197
src/bookmarks/mod.rs Normal file
View File

@@ -0,0 +1,197 @@
//! ## Bookmarks
//!
//! `bookmarks` is the module which provides data types and de/serializer for bookmarks
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
pub mod serializer;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Deserialize, Serialize, std::fmt::Debug)]
/// ## UserHosts
///
/// UserHosts contains all the hosts saved by the user in the data storage
/// It contains both `Bookmark`
pub struct UserHosts {
pub bookmarks: HashMap<String, Bookmark>,
pub recents: HashMap<String, Bookmark>,
}
#[derive(Deserialize, Serialize, std::fmt::Debug, PartialEq)]
/// ## Bookmark
///
/// Bookmark describes a single bookmark entry in the user hosts storage
pub struct Bookmark {
pub address: String,
pub port: u16,
pub protocol: String,
pub username: String,
pub password: Option<String>, // Password is optional; base64, aes-128 encrypted password
}
// Errors
/// ## SerializerError
///
/// Contains the error for serializer/deserializer
#[derive(std::fmt::Debug)]
pub struct SerializerError {
kind: SerializerErrorKind,
msg: Option<String>,
}
/// ## SerializerErrorKind
///
/// Describes the kind of error for the serializer/deserializer
#[derive(std::fmt::Debug, PartialEq)]
pub enum SerializerErrorKind {
IoError,
SerializationError,
SyntaxError,
}
impl Default for UserHosts {
fn default() -> Self {
UserHosts {
bookmarks: HashMap::new(),
recents: HashMap::new(),
}
}
}
impl SerializerError {
/// ### new
///
/// Instantiate a new `SerializerError`
pub fn new(kind: SerializerErrorKind) -> SerializerError {
SerializerError { kind, msg: None }
}
/// ### new_ex
///
/// Instantiates a new `SerializerError` with description message
pub fn new_ex(kind: SerializerErrorKind, msg: String) -> SerializerError {
let mut err: SerializerError = SerializerError::new(kind);
err.msg = Some(msg);
err
}
}
impl std::fmt::Display for SerializerError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let err: String = match &self.kind {
SerializerErrorKind::IoError => String::from("IO error"),
SerializerErrorKind::SerializationError => String::from("Serialization error"),
SerializerErrorKind::SyntaxError => String::from("Syntax error"),
};
match &self.msg {
Some(msg) => write!(f, "{} ({})", err, msg),
None => write!(f, "{}", err),
}
}
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_bookmarks_bookmark_new() {
let bookmark: Bookmark = Bookmark {
address: String::from("192.168.1.1"),
port: 22,
protocol: String::from("SFTP"),
username: String::from("root"),
password: Some(String::from("password")),
};
let recent: Bookmark = Bookmark {
address: String::from("192.168.1.2"),
port: 22,
protocol: String::from("SCP"),
username: String::from("admin"),
password: Some(String::from("password")),
};
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(1);
bookmarks.insert(String::from("test"), bookmark);
let mut recents: HashMap<String, Bookmark> = HashMap::with_capacity(1);
recents.insert(String::from("ISO20201218T181432"), recent);
let hosts: UserHosts = UserHosts {
bookmarks: bookmarks,
recents: recents,
};
// Verify
let bookmark: &Bookmark = hosts.bookmarks.get(&String::from("test")).unwrap();
assert_eq!(bookmark.address, String::from("192.168.1.1"));
assert_eq!(bookmark.port, 22);
assert_eq!(bookmark.protocol, String::from("SFTP"));
assert_eq!(bookmark.username, String::from("root"));
assert_eq!(
*bookmark.password.as_ref().unwrap(),
String::from("password")
);
let bookmark: &Bookmark = hosts
.recents
.get(&String::from("ISO20201218T181432"))
.unwrap();
assert_eq!(bookmark.address, String::from("192.168.1.2"));
assert_eq!(bookmark.port, 22);
assert_eq!(bookmark.protocol, String::from("SCP"));
assert_eq!(bookmark.username, String::from("admin"));
assert_eq!(
*bookmark.password.as_ref().unwrap(),
String::from("password")
);
}
#[test]
fn test_bookmarks_bookmark_errors() {
let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError);
assert_eq!(error.kind, SerializerErrorKind::SyntaxError);
assert!(error.msg.is_none());
assert_eq!(format!("{}", error), String::from("Syntax error"));
let error: SerializerError =
SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax"));
assert_eq!(error.kind, SerializerErrorKind::SyntaxError);
assert!(error.msg.is_some());
assert_eq!(
format!("{}", error),
String::from("Syntax error (bad syntax)")
);
// Fmt
assert_eq!(
format!("{}", SerializerError::new(SerializerErrorKind::IoError)),
String::from("IO error")
);
assert_eq!(
format!(
"{}",
SerializerError::new(SerializerErrorKind::SerializationError)
),
String::from("Serialization error")
);
}
}

220
src/bookmarks/serializer.rs Normal file
View File

@@ -0,0 +1,220 @@
//! ## Serializer
//!
//! `serializer` is the module which provides the serializer/deserializer for bookmarks
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::{SerializerError, SerializerErrorKind, UserHosts};
use std::io::{Read, Write};
pub struct BookmarkSerializer {}
impl BookmarkSerializer {
/// ### serialize
///
/// Serialize `UserHosts` into TOML and write content to writable
pub fn serialize(
&self,
mut writable: Box<dyn Write>,
hosts: &UserHosts,
) -> Result<(), SerializerError> {
// Serialize content
let data: String = match toml::ser::to_string(hosts) {
Ok(dt) => dt,
Err(err) => {
return Err(SerializerError::new_ex(
SerializerErrorKind::SerializationError,
err.to_string(),
))
}
};
// Write file
match writable.write_all(data.as_bytes()) {
Ok(_) => Ok(()),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### deserialize
///
/// Read data from readable and deserialize its content as TOML
pub fn deserialize(&self, mut readable: Box<dyn Read>) -> Result<UserHosts, SerializerError> {
// Read file content
let mut data: String = String::new();
if let Err(err) = readable.read_to_string(&mut data) {
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
));
}
// Deserialize
match toml::de::from_str(data.as_str()) {
Ok(hosts) => Ok(hosts),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::SyntaxError,
err.to_string(),
)),
}
}
}
// Tests
#[cfg(test)]
mod tests {
use super::super::Bookmark;
use super::*;
use std::collections::HashMap;
use std::io::{Seek, SeekFrom};
#[test]
fn test_bookmarks_serializer_deserialize_ok() {
let toml_file: tempfile::NamedTempFile = create_good_toml();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
// Parse
let deserializer: BookmarkSerializer = BookmarkSerializer {};
let hosts = deserializer.deserialize(Box::new(toml_file));
assert!(hosts.is_ok());
let hosts: UserHosts = hosts.ok().unwrap();
// Verify hosts
// Verify recents
assert_eq!(hosts.recents.len(), 1);
let host: &Bookmark = hosts.recents.get("ISO20201215T094000Z").unwrap();
assert_eq!(host.address, String::from("172.16.104.10"));
assert_eq!(host.port, 22);
assert_eq!(host.protocol, String::from("SCP"));
assert_eq!(host.username, String::from("root"));
assert_eq!(host.password, None);
// Verify bookmarks
assert_eq!(hosts.bookmarks.len(), 3);
let host: &Bookmark = hosts.bookmarks.get("raspberrypi2").unwrap();
assert_eq!(host.address, String::from("192.168.1.31"));
assert_eq!(host.port, 22);
assert_eq!(host.protocol, String::from("SFTP"));
assert_eq!(host.username, String::from("root"));
assert_eq!(*host.password.as_ref().unwrap(), String::from("mypassword"));
let host: &Bookmark = hosts.bookmarks.get("msi-estrem").unwrap();
assert_eq!(host.address, String::from("192.168.1.30"));
assert_eq!(host.port, 22);
assert_eq!(host.protocol, String::from("SFTP"));
assert_eq!(host.username, String::from("cvisintin"));
assert_eq!(*host.password.as_ref().unwrap(), String::from("mysecret"));
let host: &Bookmark = hosts.bookmarks.get("aws-server-prod1").unwrap();
assert_eq!(host.address, String::from("51.23.67.12"));
assert_eq!(host.port, 21);
assert_eq!(host.protocol, String::from("FTPS"));
assert_eq!(host.username, String::from("aws001"));
assert_eq!(host.password, None);
}
#[test]
fn test_bookmarks_serializer_deserialize_nok() {
let toml_file: tempfile::NamedTempFile = create_bad_toml();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
// Parse
let deserializer: BookmarkSerializer = BookmarkSerializer {};
assert!(deserializer.deserialize(Box::new(toml_file)).is_err());
}
#[test]
fn test_bookmarks_serializer_serialize() {
let mut bookmarks: HashMap<String, Bookmark> = HashMap::with_capacity(2);
// Push two samples
bookmarks.insert(
String::from("raspberrypi2"),
Bookmark {
address: String::from("192.168.1.31"),
port: 22,
protocol: String::from("SFTP"),
username: String::from("root"),
password: None,
},
);
bookmarks.insert(
String::from("msi-estrem"),
Bookmark {
address: String::from("192.168.1.30"),
port: 4022,
protocol: String::from("SFTP"),
username: String::from("cvisintin"),
password: Some(String::from("password")),
},
);
let mut recents: HashMap<String, Bookmark> = HashMap::with_capacity(1);
recents.insert(
String::from("ISO20201215T094000Z"),
Bookmark {
address: String::from("192.168.1.254"),
port: 3022,
protocol: String::from("SCP"),
username: String::from("omar"),
password: Some(String::from("aaa")),
},
);
let tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
// Serialize
let deserializer: BookmarkSerializer = BookmarkSerializer {};
let hosts: UserHosts = UserHosts { bookmarks, recents };
assert!(deserializer.serialize(Box::new(tmpfile), &hosts).is_ok());
}
fn create_good_toml() -> tempfile::NamedTempFile {
// Write
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[bookmarks]
raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root", password = "mypassword" }
msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP", username = "cvisintin", password = "mysecret" }
aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" }
[recents]
ISO20201215T094000Z = { address = "172.16.104.10", port = 22, protocol = "SCP", username = "root" }
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
//write!(tmpfile, "[bookmarks]\nraspberrypi2 = {{ address = \"192.168.1.31\", port = 22, protocol = \"SFTP\", username = \"root\" }}\nmsi-estrem = {{ address = \"192.168.1.30\", port = 22, protocol = \"SFTP\", username = \"cvisintin\" }}\naws-server-prod1 = {{ address = \"51.23.67.12\", port = 21, protocol = \"FTPS\", username = \"aws001\" }}\n\n[recents]\nISO20201215T094000Z = {{ address = \"172.16.104.10\", port = 22, protocol = \"SCP\", username = \"root\" }}\n");
tmpfile
}
fn create_bad_toml() -> tempfile::NamedTempFile {
// Write
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[bookmarks]
raspberrypi2 = { address = "192.168.1.31", port = 22, protocol = "SFTP", username = "root"}
msi-estrem = { address = "192.168.1.30", port = 22, protocol = "SFTP" }
aws-server-prod1 = { address = "51.23.67.12", port = 21, protocol = "FTPS", username = "aws001" }
[recents]
ISO20201215T094000Z = { address = "172.16.104.10", protocol = "SCP", username = "root", port = 22 }
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
}

239
src/config/mod.rs Normal file
View File

@@ -0,0 +1,239 @@
//! ## Config
//!
//! `config` is the module which provides access to termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Modules
pub mod serializer;
// Deps
extern crate edit;
// Locals
use crate::filetransfer::FileTransferProtocol;
// Ext
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::PathBuf;
#[derive(Deserialize, Serialize, std::fmt::Debug)]
/// ## UserConfig
///
/// UserConfig contains all the configurations for the user,
/// supported by termscp
pub struct UserConfig {
pub user_interface: UserInterfaceConfig,
pub remote: RemoteConfig,
}
#[derive(Deserialize, Serialize, std::fmt::Debug)]
/// ## UserInterfaceConfig
///
/// UserInterfaceConfig provides all the keys to configure the user interface
pub struct UserInterfaceConfig {
pub text_editor: PathBuf,
pub default_protocol: String,
pub show_hidden_files: bool,
pub check_for_updates: Option<bool>, // @! Since 0.3.3
pub group_dirs: Option<String>,
pub file_fmt: Option<String>,
}
#[derive(Deserialize, Serialize, std::fmt::Debug)]
/// ## RemoteConfig
///
/// Contains configuratio related to remote hosts
pub struct RemoteConfig {
pub ssh_keys: HashMap<String, PathBuf>, // Association between host name and path to private key
}
impl Default for UserConfig {
fn default() -> Self {
UserConfig {
user_interface: UserInterfaceConfig::default(),
remote: RemoteConfig::default(),
}
}
}
impl Default for UserInterfaceConfig {
fn default() -> Self {
UserInterfaceConfig {
text_editor: match edit::get_editor() {
Ok(p) => p,
Err(_) => PathBuf::from("nano"), // Default to nano
},
default_protocol: FileTransferProtocol::Sftp.to_string(),
show_hidden_files: false,
check_for_updates: Some(true),
group_dirs: None,
file_fmt: None,
}
}
}
impl Default for RemoteConfig {
fn default() -> Self {
RemoteConfig {
ssh_keys: HashMap::new(),
}
}
}
// Errors
/// ## SerializerError
///
/// Contains the error for serializer/deserializer
#[derive(std::fmt::Debug)]
pub struct SerializerError {
kind: SerializerErrorKind,
msg: Option<String>,
}
/// ## SerializerErrorKind
///
/// Describes the kind of error for the serializer/deserializer
#[derive(std::fmt::Debug, PartialEq)]
pub enum SerializerErrorKind {
IoError,
SerializationError,
SyntaxError,
}
impl SerializerError {
/// ### new
///
/// Instantiate a new `SerializerError`
pub fn new(kind: SerializerErrorKind) -> SerializerError {
SerializerError { kind, msg: None }
}
/// ### new_ex
///
/// Instantiates a new `SerializerError` with description message
pub fn new_ex(kind: SerializerErrorKind, msg: String) -> SerializerError {
let mut err: SerializerError = SerializerError::new(kind);
err.msg = Some(msg);
err
}
}
impl std::fmt::Display for SerializerError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let err: String = match &self.kind {
SerializerErrorKind::IoError => String::from("IO error"),
SerializerErrorKind::SerializationError => String::from("Serialization error"),
SerializerErrorKind::SyntaxError => String::from("Syntax error"),
};
match &self.msg {
Some(msg) => write!(f, "{} ({})", err, msg),
None => write!(f, "{}", err),
}
}
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
use std::env;
#[test]
fn test_config_mod_new() {
let mut keys: HashMap<String, PathBuf> = HashMap::with_capacity(1);
keys.insert(
String::from("192.168.1.31"),
PathBuf::from("/tmp/private.key"),
);
let remote: RemoteConfig = RemoteConfig { ssh_keys: keys };
let ui: UserInterfaceConfig = UserInterfaceConfig {
default_protocol: String::from("SFTP"),
text_editor: PathBuf::from("nano"),
show_hidden_files: true,
check_for_updates: Some(true),
group_dirs: Some(String::from("first")),
file_fmt: Some(String::from("{NAME}")),
};
let cfg: UserConfig = UserConfig {
user_interface: ui,
remote: remote,
};
assert_eq!(
*cfg.remote
.ssh_keys
.get(&String::from("192.168.1.31"))
.unwrap(),
PathBuf::from("/tmp/private.key")
);
assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP"));
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("nano"));
assert_eq!(cfg.user_interface.show_hidden_files, true);
assert_eq!(cfg.user_interface.check_for_updates, Some(true));
assert_eq!(cfg.user_interface.group_dirs, Some(String::from("first")));
assert_eq!(cfg.user_interface.file_fmt, Some(String::from("{NAME}")));
}
#[test]
fn test_config_mod_new_default() {
// Force vim editor
env::set_var(String::from("EDITOR"), String::from("vim"));
// Get default
let cfg: UserConfig = UserConfig::default();
assert_eq!(cfg.user_interface.default_protocol, String::from("SFTP"));
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim"));
assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true);
assert_eq!(cfg.remote.ssh_keys.len(), 0);
}
#[test]
fn test_config_mod_errors() {
let error: SerializerError = SerializerError::new(SerializerErrorKind::SyntaxError);
assert_eq!(error.kind, SerializerErrorKind::SyntaxError);
assert!(error.msg.is_none());
assert_eq!(format!("{}", error), String::from("Syntax error"));
let error: SerializerError =
SerializerError::new_ex(SerializerErrorKind::SyntaxError, String::from("bad syntax"));
assert_eq!(error.kind, SerializerErrorKind::SyntaxError);
assert!(error.msg.is_some());
assert_eq!(
format!("{}", error),
String::from("Syntax error (bad syntax)")
);
// Fmt
assert_eq!(
format!("{}", SerializerError::new(SerializerErrorKind::IoError)),
String::from("IO error")
);
assert_eq!(
format!(
"{}",
SerializerError::new(SerializerErrorKind::SerializationError)
),
String::from("Serialization error")
);
}
}

250
src/config/serializer.rs Normal file
View File

@@ -0,0 +1,250 @@
//! ## Serializer
//!
//! `serializer` is the module which provides the serializer/deserializer for configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::{SerializerError, SerializerErrorKind, UserConfig};
use std::io::{Read, Write};
pub struct ConfigSerializer {}
impl ConfigSerializer {
/// ### serialize
///
/// Serialize `UserConfig` into TOML and write content to writable
pub fn serialize(
&self,
mut writable: Box<dyn Write>,
cfg: &UserConfig,
) -> Result<(), SerializerError> {
// Serialize content
let data: String = match toml::ser::to_string(cfg) {
Ok(dt) => dt,
Err(err) => {
return Err(SerializerError::new_ex(
SerializerErrorKind::SerializationError,
err.to_string(),
))
}
};
// Write file
match writable.write_all(data.as_bytes()) {
Ok(_) => Ok(()),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### deserialize
///
/// Read data from readable and deserialize its content as TOML
pub fn deserialize(&self, mut readable: Box<dyn Read>) -> Result<UserConfig, SerializerError> {
// Read file content
let mut data: String = String::new();
if let Err(err) = readable.read_to_string(&mut data) {
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
));
}
// Deserialize
match toml::de::from_str(data.as_str()) {
Ok(hosts) => Ok(hosts),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::SyntaxError,
err.to_string(),
)),
}
}
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
use std::io::{Seek, SeekFrom};
use std::path::PathBuf;
#[test]
fn test_config_serializer_deserialize_ok() {
let toml_file: tempfile::NamedTempFile = create_good_toml();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
// Parse
let deserializer: ConfigSerializer = ConfigSerializer {};
let cfg = deserializer.deserialize(Box::new(toml_file));
println!("{:?}", cfg);
assert!(cfg.is_ok());
let cfg: UserConfig = cfg.ok().unwrap();
// Verify configuration
// Verify ui
assert_eq!(cfg.user_interface.default_protocol, String::from("SCP"));
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim"));
assert_eq!(cfg.user_interface.show_hidden_files, true);
assert_eq!(cfg.user_interface.check_for_updates.unwrap(), true);
assert_eq!(cfg.user_interface.group_dirs, Some(String::from("last")));
assert_eq!(
cfg.user_interface.file_fmt,
Some(String::from("{NAME} {PEX}"))
);
// Verify keys
assert_eq!(
*cfg.remote
.ssh_keys
.get(&String::from("192.168.1.31"))
.unwrap(),
PathBuf::from("/home/omar/.ssh/raspberry.key")
);
assert_eq!(
*cfg.remote
.ssh_keys
.get(&String::from("192.168.1.32"))
.unwrap(),
PathBuf::from("/home/omar/.ssh/beaglebone.key")
);
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
}
#[test]
fn test_config_serializer_deserialize_ok_no_opts() {
let toml_file: tempfile::NamedTempFile = create_good_toml_no_opts();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
// Parse
let deserializer: ConfigSerializer = ConfigSerializer {};
let cfg = deserializer.deserialize(Box::new(toml_file));
println!("{:?}", cfg);
assert!(cfg.is_ok());
let cfg: UserConfig = cfg.ok().unwrap();
// Verify configuration
// Verify ui
assert_eq!(cfg.user_interface.default_protocol, String::from("SCP"));
assert_eq!(cfg.user_interface.text_editor, PathBuf::from("vim"));
assert_eq!(cfg.user_interface.show_hidden_files, true);
assert_eq!(cfg.user_interface.group_dirs, None);
assert!(cfg.user_interface.check_for_updates.is_none());
assert_eq!(cfg.user_interface.file_fmt, None);
// Verify keys
assert_eq!(
*cfg.remote
.ssh_keys
.get(&String::from("192.168.1.31"))
.unwrap(),
PathBuf::from("/home/omar/.ssh/raspberry.key")
);
assert_eq!(
*cfg.remote
.ssh_keys
.get(&String::from("192.168.1.32"))
.unwrap(),
PathBuf::from("/home/omar/.ssh/beaglebone.key")
);
assert!(cfg.remote.ssh_keys.get(&String::from("1.1.1.1")).is_none());
}
#[test]
fn test_config_serializer_deserialize_nok() {
let toml_file: tempfile::NamedTempFile = create_bad_toml();
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
// Parse
let deserializer: ConfigSerializer = ConfigSerializer {};
assert!(deserializer.deserialize(Box::new(toml_file)).is_err());
}
#[test]
fn test_config_serializer_serialize() {
let mut cfg: UserConfig = UserConfig::default();
let toml_file: tempfile::NamedTempFile = tempfile::NamedTempFile::new().ok().unwrap();
// Insert key
cfg.remote.ssh_keys.insert(
String::from("192.168.1.31"),
PathBuf::from("/home/omar/.ssh/id_rsa"),
);
// Serialize
let serializer: ConfigSerializer = ConfigSerializer {};
let writer: Box<dyn Write> = Box::new(std::fs::File::create(toml_file.path()).unwrap());
assert!(serializer.serialize(writer, &cfg).is_ok());
// Reload configuration and check if it's ok
toml_file.as_file().sync_all().unwrap();
toml_file.as_file().seek(SeekFrom::Start(0)).unwrap();
assert!(serializer.deserialize(Box::new(toml_file)).is_ok());
}
fn create_good_toml() -> tempfile::NamedTempFile {
// Write
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[user_interface]
default_protocol = "SCP"
text_editor = "vim"
show_hidden_files = true
check_for_updates = true
group_dirs = "last"
file_fmt = "{NAME} {PEX}"
[remote.ssh_keys]
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
"192.168.1.32" = "/home/omar/.ssh/beaglebone.key"
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
fn create_good_toml_no_opts() -> tempfile::NamedTempFile {
// Write
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[user_interface]
default_protocol = "SCP"
text_editor = "vim"
show_hidden_files = true
[remote.ssh_keys]
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
"192.168.1.32" = "/home/omar/.ssh/beaglebone.key"
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
fn create_bad_toml() -> tempfile::NamedTempFile {
// Write
let mut tmpfile: tempfile::NamedTempFile = tempfile::NamedTempFile::new().unwrap();
let file_content: &str = r#"
[user_interface]
default_protocol = "SFTP"
[remote.ssh_keys]
"192.168.1.31" = "/home/omar/.ssh/raspberry.key"
"#;
tmpfile.write_all(file_content.as_bytes()).unwrap();
tmpfile
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -30,15 +30,18 @@ extern crate regex;
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::utils::lstime_to_systime;
use crate::utils::parser::{parse_datetime, parse_lstime};
// Includes
use ftp4::native_tls::TlsConnector;
use ftp4::FtpStream;
use regex::Regex;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::{
io::{Read, Write},
ops::Range,
};
/// ## FtpFileTransfer
///
@@ -60,6 +63,25 @@ impl FtpFileTransfer {
///
/// Parse a line of LIST command output and instantiates an FsEntry from it
fn parse_list_line(&self, path: &Path, line: &str) -> Result<FsEntry, ()> {
// Try to parse using UNIX syntax
match self.parse_unix_list_line(path, line) {
Ok(entry) => Ok(entry),
Err(_) => match self.parse_dos_list_line(path, line) {
// If UNIX parsing fails, try DOS
Ok(entry) => Ok(entry),
Err(_) => Err(()),
},
}
}
/// ### parse_unix_list_line
///
/// Try to parse a "LIST" output command line in UNIX format.
/// Returns error if syntax is not UNIX compliant.
/// UNIX syntax has the following syntax:
/// {FILE_TYPE}{UNIX_PEX} {HARD_LINKS} {USER} {GROUP} {SIZE} {DATE} {FILENAME}
/// -rw-r--r-- 1 cvisintin staff 4968 27 Dic 10:46 CHANGELOG.md
fn parse_unix_list_line(&self, path: &Path, line: &str) -> Result<FsEntry, ()> {
// Prepare list regex
// NOTE: about this damn regex <https://stackoverflow.com/questions/32480890/is-there-a-regex-to-parse-the-values-from-an-ftp-directory-listing>
lazy_static! {
@@ -86,63 +108,30 @@ impl FtpFileTransfer {
if metadata.get(2).unwrap().as_str().len() < 9 {
return Err(());
}
// Get unix pex
let unix_pex: (u8, u8, u8) = {
let owner_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[0..3].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
let pex = |range: Range<usize>| {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[range].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
count
};
let group_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[3..6].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
}
count
};
let others_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[6..9].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
}
count
};
(owner_pex, group_pex, others_pex)
}
count
};
// Get unix pex
let unix_pex = (pex(0..3), pex(3..6), pex(6..9));
// Parse mtime and convert to SystemTime
let mtime: SystemTime = match lstime_to_systime(
let mtime: SystemTime = match parse_lstime(
metadata.get(7).unwrap().as_str(),
"%b %d %Y",
"%b %d %H:%M",
@@ -161,21 +150,24 @@ impl FtpFileTransfer {
Err(_) => None,
};
// Get filesize
let filesize: usize = match metadata.get(6).unwrap().as_str().parse::<usize>() {
Ok(sz) => sz,
Err(_) => 0,
};
let filesize: usize = metadata
.get(6)
.unwrap()
.as_str()
.parse::<usize>()
.unwrap_or(0);
let file_name: String = String::from(metadata.get(8).unwrap().as_str());
// Check if file_name is '.' or '..'
if file_name.as_str() == "." || file_name.as_str() == ".." {
return Err(());
}
let mut abs_path: PathBuf = PathBuf::from(path);
abs_path.push(file_name.as_str());
// get extension
let extension: Option<String> = match abs_path.as_path().extension() {
None => None,
Some(s) => Some(String::from(s.to_string_lossy())),
};
abs_path.push(file_name.as_str());
// Return
// Push to entries
Ok(match is_dir {
@@ -210,6 +202,93 @@ impl FtpFileTransfer {
None => Err(()),
}
}
/// ### parse_dos_list_line
///
/// Try to parse a "LIST" output command line in DOS format.
/// Returns error if syntax is not DOS compliant.
/// DOS syntax has the following syntax:
/// {DATE} {TIME} {<DIR> | SIZE} {FILENAME}
/// 10-19-20 03:19PM <DIR> pub
/// 04-08-14 03:09PM 403 readme.txt
fn parse_dos_list_line(&self, path: &Path, line: &str) -> Result<FsEntry, ()> {
// Prepare list regex
// NOTE: you won't find this regex on the internet. It seems I'm the only person in the world who needs this
lazy_static! {
static ref DOS_RE: Regex = Regex::new(
r#"^(\d{2}\-\d{2}\-\d{2}\s+\d{2}:\d{2}\s*[AP]M)\s+(<DIR>)?([\d,]*)\s+(.+)$"#
)
.unwrap();
}
// Apply regex to result
match DOS_RE.captures(line) {
// String matches regex
Some(metadata) => {
// NOTE: metadata fmt: (regex, date_time, is_dir?, file_size?, file_name)
// Expected 4 + 1 (5) values: + 1 cause regex is repeated at 0
if metadata.len() < 5 {
return Err(());
}
// Parse date time
let time: SystemTime =
match parse_datetime(metadata.get(1).unwrap().as_str(), "%d-%m-%y %I:%M%p") {
Ok(t) => t,
Err(_) => SystemTime::UNIX_EPOCH, // Don't return error
};
// Get if is a directory
let is_dir: bool = metadata.get(2).is_some();
// Get file size
let file_size: usize = match is_dir {
true => 0, // If is directory, filesize is 0
false => match metadata.get(3) {
// If is file, parse arg 3
Some(val) => val.as_str().parse::<usize>().unwrap_or(0),
None => 0, // Should not happen
},
};
// Get file name
let file_name: String = String::from(metadata.get(4).unwrap().as_str());
// Get absolute path
let mut abs_path: PathBuf = PathBuf::from(path);
abs_path.push(file_name.as_str());
// Get extension
let extension: Option<String> = match abs_path.as_path().extension() {
None => None,
Some(s) => Some(String::from(s.to_string_lossy())),
};
// Return entry
Ok(match is_dir {
true => FsEntry::Directory(FsDirectory {
name: file_name,
abs_path,
last_change_time: time,
last_access_time: time,
creation_time: time,
readonly: false,
symlink: None,
user: None,
group: None,
unix_pex: None,
}),
false => FsEntry::File(FsFile {
name: file_name,
abs_path,
last_change_time: time,
last_access_time: time,
creation_time: time,
size: file_size,
ftype: extension,
readonly: false,
symlink: None,
user: None,
group: None,
unix_pex: None,
}),
})
}
None => Err(()), // Invalid syntax
}
}
}
impl FileTransfer for FtpFileTransfer {
@@ -344,6 +423,16 @@ impl FileTransfer for FtpFileTransfer {
}
}
/// ### copy
///
/// Copy file to destination
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
// FTP doesn't support file copy
Err(FileTransferError::new(
FileTransferErrorType::UnsupportedFeature,
))
}
/// ### list_dir
///
/// List directory entries
@@ -585,6 +674,7 @@ impl FileTransfer for FtpFileTransfer {
mod tests {
use super::*;
use crate::utils::fmt::fmt_time;
use std::time::Duration;
#[test]
@@ -599,7 +689,7 @@ mod tests {
}
#[test]
fn test_filetransfer_ftp_parse_list_line() {
fn test_filetransfer_ftp_parse_list_line_unix() {
let ftp: FtpFileTransfer = FtpFileTransfer::new(false);
// Simple file
let fs_entry: FsEntry = ftp
@@ -658,25 +748,16 @@ mod tests {
assert_eq!(file.group, Some(9));
assert_eq!(file.unix_pex.unwrap(), (7, 5, 5));
assert_eq!(
file.last_access_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1604593920)
fmt_time(file.last_access_time, "%m %d %M").as_str(),
"11 05 32"
);
assert_eq!(
file.last_change_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1604593920)
fmt_time(file.last_change_time, "%m %d %M").as_str(),
"11 05 32"
);
assert_eq!(
file.creation_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1604593920)
fmt_time(file.creation_time, "%m %d %M").as_str(),
"11 05 32"
);
} else {
panic!("Expected file, got directory");
@@ -730,6 +811,95 @@ mod tests {
.is_err());
}
#[test]
fn test_filetransfer_ftp_parse_list_line_dos() {
let ftp: FtpFileTransfer = FtpFileTransfer::new(false);
// Simple file
let fs_entry: FsEntry = ftp
.parse_list_line(
PathBuf::from("/tmp").as_path(),
"04-08-14 03:09PM 8192 omar.txt",
)
.ok()
.unwrap();
if let FsEntry::File(file) = fs_entry {
assert_eq!(file.abs_path, PathBuf::from("/tmp/omar.txt"));
assert_eq!(file.name, String::from("omar.txt"));
assert_eq!(file.size, 8192);
assert!(file.symlink.is_none());
assert_eq!(file.user, None);
assert_eq!(file.group, None);
assert_eq!(file.unix_pex, None);
assert_eq!(
file.last_access_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
assert_eq!(
file.last_change_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
assert_eq!(
file.creation_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
} else {
panic!("Expected file, got directory");
}
// Directory
let fs_entry: FsEntry = ftp
.parse_list_line(
PathBuf::from("/tmp").as_path(),
"04-08-14 03:09PM <DIR> docs",
)
.ok()
.unwrap();
if let FsEntry::Directory(dir) = fs_entry {
assert_eq!(dir.abs_path, PathBuf::from("/tmp/docs"));
assert_eq!(dir.name, String::from("docs"));
assert!(dir.symlink.is_none());
assert_eq!(dir.user, None);
assert_eq!(dir.group, None);
assert_eq!(dir.unix_pex, None);
assert_eq!(
dir.last_access_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
assert_eq!(
dir.last_change_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
assert_eq!(
dir.creation_time
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
assert_eq!(dir.readonly, false);
} else {
panic!("Expected directory, got directory");
}
// Error
assert!(ftp
.parse_list_line(PathBuf::from("/").as_path(), "04-08-14 omar.txt")
.is_err());
}
#[test]
fn test_filetransfer_ftp_connect_unsecure_anonymous() {
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
@@ -796,47 +966,78 @@ mod tests {
assert!(ftp.disconnect().is_ok());
}
/* NOTE: they don't work
#[test]
fn test_filetransfer_ftp_list_dir() {
fn test_filetransfer_sftp_copy() {
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
// Connect
assert!(ftp.connect(String::from("speedtest.tele2.net"), 21, None, None).is_ok());
assert!(ftp
.connect(String::from("speedtest.tele2.net"), 21, None, None)
.is_ok());
// Pwd
assert_eq!(ftp.pwd().ok().unwrap(), PathBuf::from("/"));
// Copy
let file: FsFile = FsFile {
name: String::from("readme.txt"),
abs_path: PathBuf::from("/readme.txt"),
last_change_time: SystemTime::UNIX_EPOCH,
last_access_time: SystemTime::UNIX_EPOCH,
creation_time: SystemTime::UNIX_EPOCH,
size: 0,
ftype: Some(String::from("txt")), // File type
readonly: true,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
};
assert!(ftp
.copy(&FsEntry::File(file), &Path::new("/tmp/dest.txt"))
.is_err());
}
#[test]
fn test_filetransfer_ftp_list_dir_dos_syntax() {
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
// Connect
assert!(ftp
.connect(
String::from("test.rebex.net"),
21,
Some(String::from("demo")),
Some(String::from("password"))
)
.is_ok());
// Pwd
assert_eq!(ftp.pwd().ok().unwrap(), PathBuf::from("/"));
// List dir
println!("{:?}", ftp.list_dir(PathBuf::from("/").as_path()));
let files: Vec<FsEntry> = ftp.list_dir(PathBuf::from("/").as_path()).ok().unwrap();
// There should be 19 files
assert_eq!(files.len(), 19);
// Verify first entry (1000GB.zip)
let first: &FsEntry = files.get(0).unwrap();
if let FsEntry::File(f) = first {
assert_eq!(f.name, String::from("1000GB.zip"));
assert_eq!(f.abs_path, PathBuf::from("/1000GB.zip"));
assert_eq!(f.size, 1073741824000);
assert_eq!(*f.ftype.as_ref().unwrap(), String::from("zip"));
assert_eq!(f.unix_pex.unwrap(), (6, 4, 4));
assert_eq!(f.creation_time.duration_since(SystemTime::UNIX_EPOCH).unwrap(), Duration::from_secs(1455840000));
assert_eq!(f.last_access_time.duration_since(SystemTime::UNIX_EPOCH).unwrap(), Duration::from_secs(1455840000));
assert_eq!(f.last_change_time.duration_since(SystemTime::UNIX_EPOCH).unwrap(), Duration::from_secs(1455840000));
} else {
panic!("First should be a file, but it a directory");
}
// Verify last entry (directory upload)
let last: &FsEntry = files.get(18).unwrap();
if let FsEntry::Directory(d) = last {
assert_eq!(d.name, String::from("upload"));
assert_eq!(d.abs_path, PathBuf::from("/upload"));
assert_eq!(d.readonly, false);
assert_eq!(d.unix_pex.unwrap(), (7, 5, 5));
} else {
panic!("Last should be a directory, but is a file");
}
// There should be at least 1 file
assert!(files.len() > 0);
// Disconnect
assert!(ftp.disconnect().is_ok());
}
#[test]
#[cfg(not(target_os = "macos"))]
fn test_filetransfer_ftp_list_dir_unix_syntax() {
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
// Connect
assert!(ftp
.connect(String::from("speedtest.tele2.net"), 21, None, None)
.is_ok());
// Pwd
assert_eq!(ftp.pwd().ok().unwrap(), PathBuf::from("/"));
// List dir
println!("{:?}", ftp.list_dir(PathBuf::from("/").as_path()));
let files: Vec<FsEntry> = ftp.list_dir(PathBuf::from("/").as_path()).ok().unwrap();
// There should be at least 1 file
assert!(files.len() > 0);
// Disconnect
assert!(ftp.disconnect().is_ok());
}
/* NOTE: they don't work
#[test]
fn test_filetransfer_ftp_recv() {
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
@@ -866,4 +1067,31 @@ mod tests {
// Disconnect
assert!(ftp.disconnect().is_ok());
}*/
#[test]
fn test_filetransfer_ftp_uninitialized() {
let file: FsFile = FsFile {
name: String::from("omar.txt"),
abs_path: PathBuf::from("/omar.txt"),
last_change_time: SystemTime::UNIX_EPOCH,
last_access_time: SystemTime::UNIX_EPOCH,
creation_time: SystemTime::UNIX_EPOCH,
size: 0,
ftype: Some(String::from("txt")), // File type
readonly: true,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
};
let mut ftp: FtpFileTransfer = FtpFileTransfer::new(false);
assert!(ftp.change_dir(Path::new("/tmp")).is_err());
assert!(ftp.disconnect().is_err());
assert!(ftp.list_dir(Path::new("/tmp")).is_err());
assert!(ftp.mkdir(Path::new("/tmp")).is_err());
assert!(ftp.pwd().is_err());
assert!(ftp.stat(Path::new("/tmp")).is_err());
assert!(ftp.recv_file(&file).is_err());
assert!(ftp.send_file(&file, Path::new("/tmp/omar.txt")).is_err());
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -37,7 +37,7 @@ pub mod sftp_transfer;
///
/// This enum defines the different transfer protocol available in TermSCP
#[derive(std::cmp::PartialEq, std::fmt::Debug, std::clone::Clone)]
#[derive(PartialEq, std::fmt::Debug, std::clone::Clone, Copy)]
pub enum FileTransferProtocol {
Sftp,
Scp,
@@ -78,10 +78,7 @@ impl FileTransferError {
///
/// Instantiates a new FileTransferError
pub fn new(code: FileTransferErrorType) -> FileTransferError {
FileTransferError {
code,
msg: None,
}
FileTransferError { code, msg: None }
}
/// ### new_ex
@@ -102,7 +99,7 @@ impl std::fmt::Display for FileTransferError {
FileTransferErrorType::ConnectionError => String::from("Connection error"),
FileTransferErrorType::DirStatFailed => String::from("Could not stat directory"),
FileTransferErrorType::FileCreateDenied => String::from("Failed to create file"),
FileTransferErrorType::IoErr(err) => format!("IO Error: {}", err),
FileTransferErrorType::IoErr(err) => format!("IO error: {}", err),
FileTransferErrorType::NoSuchFileOrDirectory => {
String::from("No such file or directory")
}
@@ -160,6 +157,11 @@ pub trait FileTransfer {
fn change_dir(&mut self, dir: &Path) -> Result<PathBuf, FileTransferError>;
/// ### copy
///
/// Copy file to destination
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError>;
/// ### list_dir
///
/// List directory entries
@@ -193,7 +195,11 @@ pub trait FileTransfer {
/// File name is referred to the name of the file as it will be saved
/// Data contains the file data
/// Returns file and its size
fn send_file(&mut self, local: &FsFile, file_name: &Path) -> Result<Box<dyn Write>, FileTransferError>;
fn send_file(
&mut self,
local: &FsFile,
file_name: &Path,
) -> Result<Box<dyn Write>, FileTransferError>;
/// ### recv_file
///
@@ -219,3 +225,190 @@ pub trait FileTransfer {
/// You must call this method each time you want to finalize the read of the remote file.
fn on_recv(&mut self, readable: Box<dyn Read>) -> Result<(), FileTransferError>;
}
// Traits
impl std::string::ToString for FileTransferProtocol {
fn to_string(&self) -> String {
String::from(match self {
FileTransferProtocol::Ftp(secure) => match secure {
true => "FTPS",
false => "FTP",
},
FileTransferProtocol::Scp => "SCP",
FileTransferProtocol::Sftp => "SFTP",
})
}
}
impl std::str::FromStr for FileTransferProtocol {
type Err = ();
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_uppercase().as_str() {
"FTP" => Ok(FileTransferProtocol::Ftp(false)),
"FTPS" => Ok(FileTransferProtocol::Ftp(true)),
"SCP" => Ok(FileTransferProtocol::Scp),
"SFTP" => Ok(FileTransferProtocol::Sftp),
_ => Err(()),
}
}
}
// Tests
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
use std::string::ToString;
#[test]
fn test_filetransfer_mod_protocol() {
assert_eq!(
FileTransferProtocol::Ftp(true),
FileTransferProtocol::Ftp(true)
);
assert_eq!(
FileTransferProtocol::Ftp(false),
FileTransferProtocol::Ftp(false)
);
// From str
assert_eq!(
FileTransferProtocol::from_str("FTPS").ok().unwrap(),
FileTransferProtocol::Ftp(true)
);
assert_eq!(
FileTransferProtocol::from_str("ftps").ok().unwrap(),
FileTransferProtocol::Ftp(true)
);
assert_eq!(
FileTransferProtocol::from_str("FTP").ok().unwrap(),
FileTransferProtocol::Ftp(false)
);
assert_eq!(
FileTransferProtocol::from_str("ftp").ok().unwrap(),
FileTransferProtocol::Ftp(false)
);
assert_eq!(
FileTransferProtocol::from_str("SFTP").ok().unwrap(),
FileTransferProtocol::Sftp
);
assert_eq!(
FileTransferProtocol::from_str("sftp").ok().unwrap(),
FileTransferProtocol::Sftp
);
assert_eq!(
FileTransferProtocol::from_str("SCP").ok().unwrap(),
FileTransferProtocol::Scp
);
assert_eq!(
FileTransferProtocol::from_str("scp").ok().unwrap(),
FileTransferProtocol::Scp
);
// Error
assert!(FileTransferProtocol::from_str("dummy").is_err());
// To String
assert_eq!(
FileTransferProtocol::Ftp(true).to_string(),
String::from("FTPS")
);
assert_eq!(
FileTransferProtocol::Ftp(false).to_string(),
String::from("FTP")
);
assert_eq!(FileTransferProtocol::Scp.to_string(), String::from("SCP"));
assert_eq!(FileTransferProtocol::Sftp.to_string(), String::from("SFTP"));
}
#[test]
fn test_filetransfer_mod_error() {
let err: FileTransferError = FileTransferError::new_ex(
FileTransferErrorType::IoErr(std::io::Error::from(std::io::ErrorKind::AddrInUse)),
String::from("non va una mazza"),
);
assert_eq!(*err.msg.as_ref().unwrap(), String::from("non va una mazza"));
assert_eq!(
format!("{}", err),
String::from("IO error: address in use (non va una mazza)")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::AuthenticationFailed)
),
String::from("Authentication failed")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::BadAddress)
),
String::from("Bad address syntax")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::ConnectionError)
),
String::from("Connection error")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::DirStatFailed)
),
String::from("Could not stat directory")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::FileCreateDenied)
),
String::from("Failed to create file")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::NoSuchFileOrDirectory)
),
String::from("No such file or directory")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::PexError)
),
String::from("Not enough permissions")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::ProtocolError)
),
String::from("Protocol error")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::SslError)
),
String::from("SSL error")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::UninitializedSession)
),
String::from("Uninitialized session")
);
assert_eq!(
format!(
"{}",
FileTransferError::new(FileTransferErrorType::UnsupportedFeature)
),
String::from("Unsupported feature")
);
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -30,15 +30,17 @@ extern crate ssh2;
// Locals
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::utils::lstime_to_systime;
use crate::system::sshkey_storage::SshKeyStorage;
use crate::utils::parser::parse_lstime;
// Includes
use regex::Regex;
use ssh2::{Channel, Session};
use std::io::{BufReader, BufWriter, Read, Write};
use std::net::TcpStream;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::ops::Range;
use std::path::{Path, PathBuf};
use std::time::SystemTime;
use std::time::{Duration, SystemTime};
/// ## ScpFileTransfer
///
@@ -46,22 +48,18 @@ use std::time::SystemTime;
pub struct ScpFileTransfer {
session: Option<Session>,
wrkdir: PathBuf,
}
impl Default for ScpFileTransfer {
fn default() -> Self {
Self::new()
}
key_storage: SshKeyStorage,
}
impl ScpFileTransfer {
/// ### new
///
/// Instantiates a new ScpFileTransfer
pub fn new() -> ScpFileTransfer {
pub fn new(key_storage: SshKeyStorage) -> ScpFileTransfer {
ScpFileTransfer {
session: None,
wrkdir: PathBuf::from("~"),
key_storage,
}
}
@@ -85,7 +83,8 @@ impl ScpFileTransfer {
}
// Collect metadata
// Get if is directory and if is symlink
let (is_dir, is_symlink): (bool, bool) = match metadata.get(1).unwrap().as_str() {
let (mut is_dir, is_symlink): (bool, bool) = match metadata.get(1).unwrap().as_str()
{
"-" => (false, false),
"l" => (false, true),
"d" => (true, false),
@@ -95,63 +94,30 @@ impl ScpFileTransfer {
if metadata.get(2).unwrap().as_str().len() < 9 {
return Err(());
}
// Get unix pex
let unix_pex: (u8, u8, u8) = {
let owner_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[0..3].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
let pex = |range: Range<usize>| {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[range].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
count
};
let group_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[3..6].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
}
count
};
let others_pex: u8 = {
let mut count: u8 = 0;
for (i, c) in metadata.get(2).unwrap().as_str()[6..9].chars().enumerate() {
match c {
'-' => {}
_ => {
count += match i {
0 => 4,
1 => 2,
2 => 1,
_ => 0,
}
}
}
}
count
};
(owner_pex, group_pex, others_pex)
}
count
};
// Get unix pex
let unix_pex = (pex(0..3), pex(3..6), pex(6..9));
// Parse mtime and convert to SystemTime
let mtime: SystemTime = match lstime_to_systime(
let mtime: SystemTime = match parse_lstime(
metadata.get(7).unwrap().as_str(),
"%b %d %Y",
"%b %d %H:%M",
@@ -170,33 +136,40 @@ impl ScpFileTransfer {
Err(_) => None,
};
// Get filesize
let filesize: usize = match metadata.get(6).unwrap().as_str().parse::<usize>() {
Ok(sz) => sz,
Err(_) => 0,
};
let filesize: usize = metadata
.get(6)
.unwrap()
.as_str()
.parse::<usize>()
.unwrap_or(0);
// Get link and name
let (file_name, symlink_path): (String, Option<PathBuf>) = match is_symlink {
true => self.get_name_and_link(metadata.get(8).unwrap().as_str()),
false => (String::from(metadata.get(8).unwrap().as_str()), None),
};
// Check if symlink points to a directory
if let Some(symlink_path) = symlink_path.as_ref() {
is_dir = symlink_path.is_dir();
}
// Get symlink
let symlink: Option<Box<FsEntry>> = match symlink_path {
None => None,
Some(p) => match self.stat(p.as_path()) {
Ok(e) => Some(Box::new(e)),
Err(_) => None, // Ignore errors
}
},
};
// Check if file_name is '.' or '..'
if file_name.as_str() == "." || file_name.as_str() == ".." {
return Err(());
}
let mut abs_path: PathBuf = PathBuf::from(path);
abs_path.push(file_name.as_str());
// Get extension
let extension: Option<String> = match abs_path.as_path().extension() {
None => None,
Some(s) => Some(String::from(s.to_string_lossy())),
};
abs_path.push(file_name.as_str());
// Return
// Push to entries
Ok(match is_dir {
@@ -313,12 +286,34 @@ impl FileTransfer for ScpFileTransfer {
password: Option<String>,
) -> Result<Option<String>, FileTransferError> {
// Setup tcp stream
let tcp: TcpStream = match TcpStream::connect(format!("{}:{}", address, port)) {
Ok(stream) => stream,
Err(err) => {
let socket_addresses: Vec<SocketAddr> =
match format!("{}:{}", address, port).to_socket_addrs() {
Ok(s) => s.collect(),
Err(err) => {
return Err(FileTransferError::new_ex(
FileTransferErrorType::BadAddress,
format!("{}", err),
))
}
};
let mut tcp: Option<TcpStream> = None;
// Try addresses
for socket_addr in socket_addresses.iter() {
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
Ok(stream) => {
tcp = Some(stream);
break;
}
Err(_) => continue,
}
}
// If stream is None, return connection timeout
let tcp: TcpStream = match tcp {
Some(t) => t,
None => {
return Err(FileTransferError::new_ex(
FileTransferErrorType::BadAddress,
format!("{}", err),
FileTransferErrorType::ConnectionError,
String::from("Connection timeout"),
))
}
};
@@ -345,17 +340,36 @@ impl FileTransfer for ScpFileTransfer {
Some(u) => u,
None => String::from(""),
};
// Try authenticating with user agent
if session.userauth_agent(username.as_str()).is_err() {
// Try authentication with password then
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
// Check if it is possible to authenticate using a RSA key
match self
.key_storage
.resolve(address.as_str(), username.as_str())
{
Some(rsa_key) => {
// Authenticate with RSA key
if let Err(err) = session.userauth_pubkey_file(
username.as_str(),
None,
rsa_key.as_path(),
password.as_deref(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
}
}
None => {
// Proceeed with username/password authentication
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
}
}
}
// Get banner
@@ -468,6 +482,47 @@ impl FileTransfer for ScpFileTransfer {
}
}
/// ### copy
///
/// Copy file to destination
fn copy(&mut self, src: &FsEntry, dst: &Path) -> Result<(), FileTransferError> {
match self.is_connected() {
true => {
// Run `cp -rf`
let p: PathBuf = self.wrkdir.clone();
match self.perform_shell_cmd_with_path(
p.as_path(),
format!(
"cp -rf \"{}\" \"{}\"; echo $?",
src.get_abs_path().display(),
dst.display()
)
.as_str(),
) {
Ok(output) =>
// Check if output is 0
{
match output.as_str().trim() == "0" {
true => Ok(()), // File copied
false => Err(FileTransferError::new_ex(
// Could not copy file
FileTransferErrorType::FileCreateDenied,
format!("\"{}\"", dst.display()),
)),
}
}
Err(err) => Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
format!("{}", err),
)),
}
}
false => Err(FileTransferError::new(
FileTransferErrorType::UninitializedSession,
)),
}
}
/// ### list_dir
///
/// List directory entries
@@ -713,8 +768,14 @@ impl FileTransfer for ScpFileTransfer {
};
(mtime, atime)
};
match session.scp_send(file_name, mode, local.size as u64, Some(times)) {
Ok(channel) => Ok(Box::new(BufWriter::with_capacity(8192, channel))),
// We need to get the size of local; NOTE: don't use the `size` attribute, since might be out of sync
let file_size: u64 = match std::fs::metadata(local.abs_path.as_path()) {
Ok(metadata) => metadata.len(),
Err(_) => local.size as u64, // NOTE: fallback to fsentry size
};
// Send file
match session.scp_send(file_name, mode, file_size, Some(times)) {
Ok(channel) => Ok(Box::new(BufWriter::with_capacity(65536, channel))),
Err(err) => Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
format!("{}", err),
@@ -737,7 +798,7 @@ impl FileTransfer for ScpFileTransfer {
// Set blocking to true
session.set_blocking(true);
match session.scp_recv(file.abs_path.as_path()) {
Ok(reader) => Ok(Box::new(BufReader::with_capacity(8192, reader.0))),
Ok(reader) => Ok(Box::new(BufReader::with_capacity(65536, reader.0))),
Err(err) => Err(FileTransferError::new_ex(
FileTransferErrorType::ProtocolError,
format!("{}", err),
@@ -782,14 +843,14 @@ mod tests {
#[test]
fn test_filetransfer_scp_new() {
let client: ScpFileTransfer = ScpFileTransfer::new();
let client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client.session.is_none());
assert_eq!(client.is_connected(), false);
}
#[test]
fn test_filetransfer_scp_connect() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert_eq!(client.is_connected(), false);
assert!(client
.connect(
@@ -808,7 +869,7 @@ mod tests {
}
#[test]
fn test_filetransfer_scp_bad_auth() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -821,7 +882,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_no_credentials() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(String::from("test.rebex.net"), 22, None, None)
.is_err());
@@ -829,7 +890,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_bad_server() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("mybadserver.veryverybad.awful"),
@@ -841,7 +902,7 @@ mod tests {
}
#[test]
fn test_filetransfer_scp_pwd() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -861,7 +922,7 @@ mod tests {
#[test]
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
fn test_filetransfer_scp_cwd() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -882,7 +943,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_cwd_error() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -905,7 +966,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_ls() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -926,7 +987,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_stat() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -950,7 +1011,7 @@ mod tests {
#[test]
fn test_filetransfer_scp_recv() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -982,7 +1043,7 @@ mod tests {
}
#[test]
fn test_filetransfer_scp_recv_failed_nosuchfile() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -1017,7 +1078,7 @@ mod tests {
/* NOTE: the server doesn't allow you to create directories
#[test]
fn test_filetransfer_scp_mkdir() {
let mut client: ScpFileTransfer = ScpFileTransfer::new();
let mut client: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(client.connect(String::from("test.rebex.net"), 22, Some(String::from("demo")), Some(String::from("password"))).is_ok());
let dir: String = String::from("foo");
// Mkdir
@@ -1029,4 +1090,31 @@ mod tests {
assert!(client.disconnect().is_ok());
}
*/
#[test]
fn test_filetransfer_scp_uninitialized() {
let file: FsFile = FsFile {
name: String::from("omar.txt"),
abs_path: PathBuf::from("/omar.txt"),
last_change_time: SystemTime::UNIX_EPOCH,
last_access_time: SystemTime::UNIX_EPOCH,
creation_time: SystemTime::UNIX_EPOCH,
size: 0,
ftype: Some(String::from("txt")), // File type
readonly: true,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
};
let mut scp: ScpFileTransfer = ScpFileTransfer::new(SshKeyStorage::empty());
assert!(scp.change_dir(Path::new("/tmp")).is_err());
assert!(scp.disconnect().is_err());
assert!(scp.list_dir(Path::new("/tmp")).is_err());
assert!(scp.mkdir(Path::new("/tmp")).is_err());
assert!(scp.pwd().is_err());
assert!(scp.stat(Path::new("/tmp")).is_err());
assert!(scp.recv_file(&file).is_err());
assert!(scp.send_file(&file, Path::new("/tmp/omar.txt")).is_err());
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -29,11 +29,12 @@ extern crate ssh2;
// Locals
use super::{FileTransfer, FileTransferError, FileTransferErrorType};
use crate::fs::{FsDirectory, FsEntry, FsFile};
use crate::system::sshkey_storage::SshKeyStorage;
// Includes
use ssh2::{FileStat, OpenFlags, OpenType, Session, Sftp};
use std::io::{BufReader, BufWriter, Read, Write};
use std::net::TcpStream;
use std::net::{SocketAddr, TcpStream, ToSocketAddrs};
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
@@ -44,23 +45,19 @@ pub struct SftpFileTransfer {
session: Option<Session>,
sftp: Option<Sftp>,
wrkdir: PathBuf,
}
impl Default for SftpFileTransfer {
fn default() -> Self {
Self::new()
}
key_storage: SshKeyStorage,
}
impl SftpFileTransfer {
/// ### new
///
/// Instantiates a new SftpFileTransfer
pub fn new() -> SftpFileTransfer {
pub fn new(key_storage: SshKeyStorage) -> SftpFileTransfer {
SftpFileTransfer {
session: None,
sftp: None,
wrkdir: PathBuf::from("~"),
key_storage,
}
}
@@ -206,12 +203,34 @@ impl FileTransfer for SftpFileTransfer {
password: Option<String>,
) -> Result<Option<String>, FileTransferError> {
// Setup tcp stream
let tcp: TcpStream = match TcpStream::connect(format!("{}:{}", address, port)) {
Ok(stream) => stream,
Err(err) => {
let socket_addresses: Vec<SocketAddr> =
match format!("{}:{}", address, port).to_socket_addrs() {
Ok(s) => s.collect(),
Err(err) => {
return Err(FileTransferError::new_ex(
FileTransferErrorType::BadAddress,
format!("{}", err),
))
}
};
let mut tcp: Option<TcpStream> = None;
// Try addresses
for socket_addr in socket_addresses.iter() {
match TcpStream::connect_timeout(&socket_addr, Duration::from_secs(30)) {
Ok(stream) => {
tcp = Some(stream);
break;
}
Err(_) => continue,
}
}
// If stream is None, return connection timeout
let tcp: TcpStream = match tcp {
Some(t) => t,
None => {
return Err(FileTransferError::new_ex(
FileTransferErrorType::BadAddress,
format!("{}", err),
FileTransferErrorType::ConnectionError,
String::from("Connection timeout"),
))
}
};
@@ -238,17 +257,36 @@ impl FileTransfer for SftpFileTransfer {
Some(u) => u,
None => String::from(""),
};
// Try authenticating with user agent
if session.userauth_agent(username.as_str()).is_err() {
// Try authentication with password then
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
// Check if it is possible to authenticate using a RSA key
match self
.key_storage
.resolve(address.as_str(), username.as_str())
{
Some(rsa_key) => {
// Authenticate with RSA key
if let Err(err) = session.userauth_pubkey_file(
username.as_str(),
None,
rsa_key.as_path(),
password.as_deref(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
}
}
None => {
// Proceeed with username/password authentication
if let Err(err) = session.userauth_password(
username.as_str(),
password.unwrap_or_else(|| String::from("")).as_str(),
) {
return Err(FileTransferError::new_ex(
FileTransferErrorType::AuthenticationFailed,
format!("{}", err),
));
}
}
}
// Set blocking to true
@@ -348,6 +386,16 @@ impl FileTransfer for SftpFileTransfer {
}
}
/// ### copy
///
/// Copy file to destination
fn copy(&mut self, _src: &FsEntry, _dst: &Path) -> Result<(), FileTransferError> {
// SFTP doesn't support file copy
Err(FileTransferError::new(
FileTransferErrorType::UnsupportedFeature,
))
}
/// ### list_dir
///
/// List directory entries
@@ -552,7 +600,7 @@ impl FileTransfer for SftpFileTransfer {
};
// Open remote file
match sftp.open(remote_path.as_path()) {
Ok(file) => Ok(Box::new(BufReader::with_capacity(8192, file))),
Ok(file) => Ok(Box::new(BufReader::with_capacity(65536, file))),
Err(err) => Err(FileTransferError::new_ex(
FileTransferErrorType::NoSuchFileOrDirectory,
format!("{}", err),
@@ -590,7 +638,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_new() {
let client: SftpFileTransfer = SftpFileTransfer::new();
let client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client.session.is_none());
assert!(client.sftp.is_none());
assert_eq!(client.wrkdir, PathBuf::from("~"));
@@ -599,7 +647,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_connect() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert_eq!(client.is_connected(), false);
assert!(client
.connect(
@@ -621,7 +669,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_bad_auth() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -634,7 +682,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_no_credentials() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(String::from("test.rebex.net"), 22, None, None)
.is_err());
@@ -642,7 +690,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_bad_server() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("mybadserver.veryverybad.awful"),
@@ -655,7 +703,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_pwd() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -676,7 +724,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_cwd() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -701,9 +749,44 @@ mod tests {
assert!(client.disconnect().is_ok());
}
#[test]
fn test_filetransfer_sftp_copy() {
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
22,
Some(String::from("demo")),
Some(String::from("password"))
)
.is_ok());
// Check session and sftp
assert!(client.session.is_some());
assert!(client.sftp.is_some());
assert_eq!(client.wrkdir, PathBuf::from("/"));
// Copy
let file: FsFile = FsFile {
name: String::from("readme.txt"),
abs_path: PathBuf::from("/readme.txt"),
last_change_time: SystemTime::UNIX_EPOCH,
last_access_time: SystemTime::UNIX_EPOCH,
creation_time: SystemTime::UNIX_EPOCH,
size: 0,
ftype: Some(String::from("txt")), // File type
readonly: true,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
};
assert!(client
.copy(&FsEntry::File(file), &Path::new("/tmp/dest.txt"))
.is_err());
}
#[test]
fn test_filetransfer_sftp_cwd_error() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -726,7 +809,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_ls() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -749,7 +832,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_stat() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -775,7 +858,7 @@ mod tests {
#[test]
fn test_filetransfer_sftp_recv() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -809,7 +892,7 @@ mod tests {
}
#[test]
fn test_filetransfer_sftp_recv_failed_nosuchfile() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client
.connect(
String::from("test.rebex.net"),
@@ -847,7 +930,7 @@ mod tests {
/* NOTE: the server doesn't allow you to create directories
#[test]
fn test_filetransfer_sftp_mkdir() {
let mut client: SftpFileTransfer = SftpFileTransfer::new();
let mut client: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(client.connect(String::from("test.rebex.net"), 22, Some(String::from("demo")), Some(String::from("password"))).is_ok());
let dir: String = String::from("foo");
// Mkdir
@@ -859,4 +942,31 @@ mod tests {
assert!(client.disconnect().is_ok());
}
*/
#[test]
fn test_filetransfer_sftp_uninitialized() {
let file: FsFile = FsFile {
name: String::from("omar.txt"),
abs_path: PathBuf::from("/omar.txt"),
last_change_time: SystemTime::UNIX_EPOCH,
last_access_time: SystemTime::UNIX_EPOCH,
creation_time: SystemTime::UNIX_EPOCH,
size: 0,
ftype: Some(String::from("txt")), // File type
readonly: true,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
};
let mut sftp: SftpFileTransfer = SftpFileTransfer::new(SshKeyStorage::empty());
assert!(sftp.change_dir(Path::new("/tmp")).is_err());
assert!(sftp.disconnect().is_err());
assert!(sftp.list_dir(Path::new("/tmp")).is_err());
assert!(sftp.mkdir(Path::new("/tmp")).is_err());
assert!(sftp.pwd().is_err());
assert!(sftp.stat(Path::new("/tmp")).is_err());
assert!(sftp.recv_file(&file).is_err());
assert!(sftp.send_file(&file, Path::new("/tmp/omar.txt")).is_err());
}
}

143
src/fs/explorer/builder.rs Normal file
View File

@@ -0,0 +1,143 @@
//! ## Builder
//!
//! `builder` is the module which provides a builder for FileExplorer
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::formatter::Formatter;
use super::{ExplorerOpts, FileExplorer, FileSorting, GroupDirs};
// Ext
use std::collections::VecDeque;
/// ## FileExplorerBuilder
///
/// Struct used to create a `FileExplorer`
pub struct FileExplorerBuilder {
explorer: Option<FileExplorer>,
}
impl FileExplorerBuilder {
/// ### new
///
/// Build a new `FileExplorerBuilder`
pub fn new() -> Self {
FileExplorerBuilder {
explorer: Some(FileExplorer::default()),
}
}
/// ### build
///
/// Take FileExplorer out of builder
pub fn build(&mut self) -> FileExplorer {
self.explorer.take().unwrap()
}
/// ### with_hidden_files
///
/// Enable HIDDEN_FILES option
pub fn with_hidden_files(&mut self, val: bool) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
match val {
true => e.opts.insert(ExplorerOpts::SHOW_HIDDEN_FILES),
false => e.opts.remove(ExplorerOpts::SHOW_HIDDEN_FILES),
}
}
self
}
/// ### with_file_sorting
///
/// Set sorting method
pub fn with_file_sorting(&mut self, sorting: FileSorting) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
e.sort_by(sorting);
}
self
}
/// ### with_dirs_first
///
/// Enable DIRS_FIRST option
pub fn with_group_dirs(&mut self, group_dirs: Option<GroupDirs>) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
e.group_dirs_by(group_dirs);
}
self
}
/// ### with_stack_size
///
/// Set stack size for FileExplorer
pub fn with_stack_size(&mut self, sz: usize) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
e.stack_size = sz;
e.dirstack = VecDeque::with_capacity(sz);
}
self
}
/// ### with_formatter
///
/// Set formatter for FileExplorer
pub fn with_formatter(&mut self, fmt_str: Option<&str>) -> &mut FileExplorerBuilder {
if let Some(e) = self.explorer.as_mut() {
if let Some(fmt_str) = fmt_str {
e.fmt = Formatter::new(fmt_str);
}
}
self
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_fs_explorer_builder_new_default() {
let explorer: FileExplorer = FileExplorerBuilder::new().build();
// Verify
assert!(!explorer.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES));
assert_eq!(explorer.file_sorting, FileSorting::ByName); // Default
assert_eq!(explorer.group_dirs, None);
assert_eq!(explorer.stack_size, 16);
}
#[test]
fn test_fs_explorer_builder_new_all() {
let explorer: FileExplorer = FileExplorerBuilder::new()
.with_file_sorting(FileSorting::ByModifyTime)
.with_group_dirs(Some(GroupDirs::First))
.with_hidden_files(true)
.with_stack_size(24)
.with_formatter(Some("{NAME}"))
.build();
// Verify
assert!(explorer.opts.intersects(ExplorerOpts::SHOW_HIDDEN_FILES));
assert_eq!(explorer.file_sorting, FileSorting::ByModifyTime); // Default
assert_eq!(explorer.group_dirs, Some(GroupDirs::First));
assert_eq!(explorer.stack_size, 24);
}
}

View File

@@ -0,0 +1,908 @@
//! ## Formatter
//!
//! `formatter` is the module which provides formatting utilities for `FileExplorer`
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate bytesize;
extern crate regex;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
extern crate users;
// Locals
use super::FsEntry;
use crate::utils::fmt::{fmt_path_elide, fmt_pex, fmt_time};
// Ext
use bytesize::ByteSize;
use regex::Regex;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
use users::{get_group_by_gid, get_user_by_uid};
// Types
// FmtCallback: Formatter, fsentry: &FsEntry, cur_str, prefix, length, extra
type FmtCallback = fn(&Formatter, &FsEntry, &str, &str, Option<&usize>, Option<&String>) -> String;
// Keys
const FMT_KEY_ATIME: &str = "ATIME";
const FMT_KEY_CTIME: &str = "CTIME";
const FMT_KEY_GROUP: &str = "GROUP";
const FMT_KEY_MTIME: &str = "MTIME";
const FMT_KEY_NAME: &str = "NAME";
const FMT_KEY_PEX: &str = "PEX";
const FMT_KEY_SIZE: &str = "SIZE";
const FMT_KEY_SYMLINK: &str = "SYMLINK";
const FMT_KEY_USER: &str = "USER";
// Default
const FMT_DEFAULT_STX: &str = "{NAME} {PEX} {USER} {SIZE} {MTIME}";
// Regex
lazy_static! {
/**
* Regex matches:
* - group 0: KEY NAME
* - group 1?: LENGTH
* - group 2?: EXTRA
*/
static ref FMT_KEY_REGEX: Regex = Regex::new(r"\{(.*?)\}").ok().unwrap();
static ref FMT_ATTR_REGEX: Regex = Regex::new(r"(?:([A-Z]+))(:?([0-9]+))?(:?(.+))?").ok().unwrap();
}
/// ## CallChainBlock
///
/// Call Chain block is a block in a chain of functions which are called in order to format the FsEntry.
/// A callChain is instantiated starting from the Formatter syntax and the regex, once the groups are found
/// a chain of function is made using the Formatters method.
/// This method provides an extremely fast way to format fs entries
struct CallChainBlock {
func: FmtCallback,
prefix: String,
fmt_len: Option<usize>,
fmt_extra: Option<String>,
next_block: Option<Box<CallChainBlock>>,
}
impl CallChainBlock {
/// ### new
///
/// Create a new `CallChainBlock`
pub fn new(
func: FmtCallback,
prefix: String,
fmt_len: Option<usize>,
fmt_extra: Option<String>,
) -> Self {
CallChainBlock {
func,
prefix,
fmt_len,
fmt_extra,
next_block: None,
}
}
/// ### next
///
/// Call next callback in the CallChain
pub fn next(&self, fmt: &Formatter, fsentry: &FsEntry, cur_str: &str) -> String {
// Call func
let new_str: String = (self.func)(
fmt,
fsentry,
cur_str,
self.prefix.as_str(),
self.fmt_len.as_ref(),
self.fmt_extra.as_ref(),
);
// If next is some, call next, otherwise (END OF CHAIN) return new_str
match &self.next_block {
Some(block) => block.next(fmt, fsentry, new_str.as_str()),
None => new_str,
}
}
/// ### push
///
/// Push func to the last element in the Call chain
pub fn push(
&mut self,
func: FmtCallback,
prefix: String,
fmt_len: Option<usize>,
fmt_extra: Option<String>,
) {
// Call recursively until an element with next_block equal to None is found
match &mut self.next_block {
None => {
self.next_block = Some(Box::new(CallChainBlock::new(
func, prefix, fmt_len, fmt_extra,
)))
}
Some(block) => block.push(func, prefix, fmt_len, fmt_extra),
}
}
}
/// ## Formatter
///
/// Formatter takes care of formatting FsEntries according to the provided keys.
/// Formatting is performed using the `CallChainBlock`, which composed makes a Call Chain. This method is extremely fast compared to match the format groups
/// at each fmt call.
pub struct Formatter {
call_chain: CallChainBlock,
}
impl Default for Formatter {
/// ### default
///
/// Instantiates a Formatter with the default fmt syntax
fn default() -> Self {
Formatter {
call_chain: Self::make_callchain(FMT_DEFAULT_STX),
}
}
}
impl Formatter {
/// ### new
///
/// Instantiates a new `Formatter` with the provided format string
pub fn new(fmt_str: &str) -> Self {
Formatter {
call_chain: Self::make_callchain(fmt_str),
}
}
/// ### fmt
///
/// Format fsentry
pub fn fmt(&self, fsentry: &FsEntry) -> String {
// Execute callchain blocks
self.call_chain.next(self, fsentry, "")
}
// Fmt methods
/// ### fmt_atime
///
/// Format last access time
fn fmt_atime(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
fmt_extra: Option<&String>,
) -> String {
// Get date (use extra args as format or default "%b %d %Y %H:%M")
let datetime: String = fmt_time(
fsentry.get_last_access_time(),
match fmt_extra {
Some(fmt) => fmt.as_ref(),
None => "%b %d %Y %H:%M",
},
);
// Add to cur str, prefix and the key value
format!(
"{}{}{:0width$}",
cur_str,
prefix,
datetime,
width = fmt_len.unwrap_or(&17)
)
}
/// ### fmt_ctime
///
/// Format creation time
fn fmt_ctime(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
fmt_extra: Option<&String>,
) -> String {
// Get date
let datetime: String = fmt_time(
fsentry.get_creation_time(),
match fmt_extra {
Some(fmt) => fmt.as_ref(),
None => "%b %d %Y %H:%M",
},
);
// Add to cur str, prefix and the key value
format!(
"{}{}{:0width$}",
cur_str,
prefix,
datetime,
width = fmt_len.unwrap_or(&17)
)
}
/// ### fmt_group
///
/// Format owner group
fn fmt_group(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Get username
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
let group: String = match fsentry.get_group() {
Some(gid) => match get_group_by_gid(gid) {
Some(user) => user.name().to_string_lossy().to_string(),
None => gid.to_string(),
},
None => 0.to_string(),
};
#[cfg(target_os = "windows")]
let group: String = match fsentry.get_group() {
Some(gid) => gid.to_string(),
None => 0.to_string(),
};
// Add to cur str, prefix and the key value
format!(
"{}{}{:0width$}",
cur_str,
prefix,
group,
width = fmt_len.unwrap_or(&12)
)
}
/// ### fmt_mtime
///
/// Format last change time
fn fmt_mtime(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
fmt_extra: Option<&String>,
) -> String {
// Get date
let datetime: String = fmt_time(
fsentry.get_last_change_time(),
match fmt_extra {
Some(fmt) => fmt.as_ref(),
None => "%b %d %Y %H:%M",
},
);
// Add to cur str, prefix and the key value
format!(
"{}{}{:0width$}",
cur_str,
prefix,
datetime,
width = fmt_len.unwrap_or(&17)
)
}
/// ### fmt_name
///
/// Format file name
fn fmt_name(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Get file name (or elide if too long)
let file_len: usize = match fmt_len {
Some(l) => *l,
None => 24,
};
let name: &str = fsentry.get_name();
let last_idx: usize = match fsentry.is_dir() {
// NOTE: For directories is 19, since we push '/' to name
true => file_len - 5,
false => file_len - 4,
};
let mut name: String = match name.len() >= file_len {
false => name.to_string(),
true => format!("{}...", &name[0..last_idx]),
};
if fsentry.is_dir() {
name.push('/');
}
// Add to cur str, prefix and the key value
format!("{}{}{:0width$}", cur_str, prefix, name, width = file_len)
}
/// ### fmt_pex
///
/// Format file permissions
fn fmt_pex(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
_fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Create mode string
let mut pex: String = String::with_capacity(10);
let file_type: char = match fsentry.is_symlink() {
true => 'l',
false => match fsentry.is_dir() {
true => 'd',
false => '-',
},
};
pex.push(file_type);
match fsentry.get_unix_pex() {
None => pex.push_str("?????????"),
Some((owner, group, others)) => pex.push_str(fmt_pex(owner, group, others).as_str()),
}
// Add to cur str, prefix and the key value
format!("{}{}{:10}", cur_str, prefix, pex)
}
/// ### fmt_size
///
/// Format file size
fn fmt_size(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
_fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
if fsentry.is_file() {
// Get byte size
let size: ByteSize = ByteSize(fsentry.get_size() as u64);
// Add to cur str, prefix and the key value
format!("{}{}{:10}", cur_str, prefix, size.to_string())
} else {
// Add to cur str, prefix and the key value
format!("{}{} ", cur_str, prefix)
}
}
/// ### fmt_symlink
///
/// Format file symlink (if any)
fn fmt_symlink(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Get file name (or elide if too long)
let file_len: usize = match fmt_len {
Some(l) => *l,
None => 21,
};
// Replace `FMT_KEY_NAME` with name
match fsentry.is_symlink() {
false => format!("{}{} ", cur_str, prefix),
true => format!(
"{}{}-> {:0width$}",
cur_str,
prefix,
fmt_path_elide(
fsentry.get_realfile().get_abs_path().as_path(),
file_len - 1
),
width = file_len
),
}
}
/// ### fmt_user
///
/// Format owner user
fn fmt_user(
&self,
fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
_fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Get username
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
let username: String = match fsentry.get_user() {
Some(uid) => match get_user_by_uid(uid) {
Some(user) => user.name().to_string_lossy().to_string(),
None => uid.to_string(),
},
None => 0.to_string(),
};
#[cfg(target_os = "windows")]
let username: String = match fsentry.get_user() {
Some(uid) => uid.to_string(),
None => 0.to_string(),
};
// Add to cur str, prefix and the key value
format!("{}{}{:12}", cur_str, prefix, username)
}
/// ### fmt_fallback
///
/// Fallback function in case the format key is unknown
/// It does nothing, just returns cur_str
fn fmt_fallback(
&self,
_fsentry: &FsEntry,
cur_str: &str,
prefix: &str,
_fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
// Add to cur str and prefix
format!("{}{}", cur_str, prefix)
}
// Static
/// ### make_callchain
///
/// Make a callchain starting from the fmt str
fn make_callchain(fmt_str: &str) -> CallChainBlock {
// Init chain block
let mut callchain: Option<CallChainBlock> = None;
// Track index of the last match found, to get the prefix for each token
let mut last_index: usize = 0;
// Match fmt str against regex
for regex_match in FMT_KEY_REGEX.captures_iter(fmt_str) {
// Get match index (unwrap is safe, since always exists)
let index: usize = fmt_str.find(&regex_match[0]).unwrap();
// Get prefix
let prefix: String = String::from(&fmt_str[last_index..index]);
// Increment last index (sum prefix lenght and the length of the key)
last_index += prefix.len() + regex_match[0].len();
// Match attributes
match FMT_ATTR_REGEX.captures(&regex_match[1]) {
Some(regex_match) => {
// Match group 0 (which is name)
let callback: FmtCallback = match &regex_match.get(1) {
Some(key) => match key.as_str() {
FMT_KEY_ATIME => Self::fmt_atime,
FMT_KEY_CTIME => Self::fmt_ctime,
FMT_KEY_GROUP => Self::fmt_group,
FMT_KEY_MTIME => Self::fmt_mtime,
FMT_KEY_NAME => Self::fmt_name,
FMT_KEY_PEX => Self::fmt_pex,
FMT_KEY_SIZE => Self::fmt_size,
FMT_KEY_SYMLINK => Self::fmt_symlink,
FMT_KEY_USER => Self::fmt_user,
_ => Self::fmt_fallback,
},
None => Self::fmt_fallback,
};
// Match format length: group 3
let fmt_len: Option<usize> = match &regex_match.get(3) {
Some(len) => match len.as_str().parse::<usize>() {
Ok(len) => Some(len),
Err(_) => None,
},
None => None,
};
// Match format extra: group 2 + 1
let fmt_extra: Option<String> = match &regex_match.get(5) {
Some(extra) => Some(extra.as_str().to_string()),
None => None,
};
// Create a callchain or push new element to its back
match callchain.as_mut() {
None => {
callchain =
Some(CallChainBlock::new(callback, prefix, fmt_len, fmt_extra))
}
Some(chain_block) => chain_block.push(callback, prefix, fmt_len, fmt_extra),
}
}
None => continue,
}
}
// Finalize and return
match callchain {
Some(callchain) => callchain,
None => CallChainBlock::new(Self::fmt_fallback, String::new(), None, None),
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::fs::{FsDirectory, FsFile};
use std::path::PathBuf;
use std::time::SystemTime;
#[test]
fn test_fs_explorer_formatter_callchain() {
// Make a dummy formatter
let dummy_formatter: Formatter = Formatter::new("");
// Make a dummy entry
let t_now: SystemTime = SystemTime::now();
let dummy_entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
let prefix: String = String::from("h");
let mut callchain: CallChainBlock = CallChainBlock::new(dummy_fmt, prefix, None, None);
assert!(callchain.next_block.is_none());
assert_eq!(callchain.prefix, String::from("h"));
// Execute
assert_eq!(
callchain.next(&dummy_formatter, &dummy_entry, ""),
String::from("hA")
);
// Push 4 new blocks
callchain.push(dummy_fmt, String::from("h"), None, None);
callchain.push(dummy_fmt, String::from("h"), None, None);
callchain.push(dummy_fmt, String::from("h"), None, None);
callchain.push(dummy_fmt, String::from("h"), None, None);
// Verify
assert_eq!(
callchain.next(&dummy_formatter, &dummy_entry, ""),
String::from("hAhAhAhAhA")
);
}
#[test]
fn test_fs_explorer_formatter_format_files() {
// Make default
let formatter: Formatter = Formatter::default();
// Experiments :D
let t: SystemTime = SystemTime::now();
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -rw-r--r-- root 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -rw-r--r-- 0 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
// Elide name
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("piroparoporoperoperupupu.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"piroparoporoperoperu... -rw-r--r-- root 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"piroparoporoperoperu... -rw-r--r-- 0 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
// No pex
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: None, // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -????????? root 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -????????? 0 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
// No user
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: None, // UNIX only
group: Some(0), // UNIX only
unix_pex: None, // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -????????? 0 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"bar.txt -????????? 0 8.2 KB {}",
fmt_time(t, "%b %d %Y %H:%M")
)
);
}
#[test]
fn test_fs_explorer_formatter_format_dirs() {
// Make default
let formatter: Formatter = Formatter::default();
// Experiments :D
let t_now: SystemTime = SystemTime::now();
let entry: FsEntry = FsEntry::Directory(FsDirectory {
name: String::from("projects"),
abs_path: PathBuf::from("/home/cvisintin/projects"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
readonly: false,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((7, 5, 5)), // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"projects/ drwxr-xr-x root {}",
fmt_time(t_now, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"projects/ drwxr-xr-x 0 {}",
fmt_time(t_now, "%b %d %Y %H:%M")
)
);
// No pex, no user
let entry: FsEntry = FsEntry::Directory(FsDirectory {
name: String::from("projects"),
abs_path: PathBuf::from("/home/cvisintin/projects"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
readonly: false,
symlink: None, // UNIX only
user: None, // UNIX only
group: Some(0), // UNIX only
unix_pex: None, // UNIX only
});
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
assert_eq!(
formatter.fmt(&entry),
format!(
"projects/ d????????? 0 {}",
fmt_time(t_now, "%b %d %Y %H:%M")
)
);
#[cfg(target_os = "windows")]
assert_eq!(
formatter.fmt(&entry),
format!(
"projects/ d????????? 0 {}",
fmt_time(t_now, "%b %d %Y %H:%M")
)
);
}
#[test]
fn test_fs_explorer_formatter_all_together_now() {
let formatter: Formatter =
Formatter::new("{NAME:16} {SYMLINK:12} {GROUP} {USER} {PEX} {SIZE} {ATIME:20:%a %b %d %Y %H:%M} {CTIME:20:%a %b %d %Y %H:%M} {MTIME:20:%a %b %d %Y %H:%M}");
// Directory (with symlink)
let t: SystemTime = SystemTime::now();
let pointer: FsEntry = FsEntry::File(FsFile {
name: String::from("project.info"),
abs_path: PathBuf::from("/project.info"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: None, // UNIX only
});
let entry: FsEntry = FsEntry::Directory(FsDirectory {
name: String::from("projects"),
abs_path: PathBuf::from("/home/cvisintin/project"),
last_change_time: t,
last_access_time: t,
creation_time: t,
readonly: false,
symlink: Some(Box::new(pointer)), // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: Some((7, 5, 5)), // UNIX only
});
assert_eq!(formatter.fmt(&entry), format!(
"projects/ -> project.info 0 0 lrwxr-xr-x {} {} {}",
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
));
// Directory without symlink
let entry: FsEntry = FsEntry::Directory(FsDirectory {
name: String::from("projects"),
abs_path: PathBuf::from("/home/cvisintin/project"),
last_change_time: t,
last_access_time: t,
creation_time: t,
readonly: false,
symlink: None, // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: Some((7, 5, 5)), // UNIX only
});
assert_eq!(formatter.fmt(&entry), format!(
"projects/ 0 0 drwxr-xr-x {} {} {}",
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
));
// File with symlink
let pointer: FsEntry = FsEntry::File(FsFile {
name: String::from("project.info"),
abs_path: PathBuf::from("/project.info"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: None, // UNIX only
});
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: Some(Box::new(pointer)), // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
assert_eq!(formatter.fmt(&entry), format!(
"bar.txt -> project.info 0 0 lrw-r--r-- 8.2 KB {} {} {}",
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
));
// File without symlink
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t,
last_access_time: t,
creation_time: t,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: None, // UNIX only
group: None, // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
assert_eq!(formatter.fmt(&entry), format!(
"bar.txt 0 0 -rw-r--r-- 8.2 KB {} {} {}",
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
fmt_time(t, "%a %b %d %Y %H:%M"),
));
}
/// ### dummy_fmt
///
/// Dummy formatter, just yelds an 'A' at the end of the current string
fn dummy_fmt(
_fmt: &Formatter,
_entry: &FsEntry,
cur_str: &str,
prefix: &str,
_fmt_len: Option<&usize>,
_fmt_extra: Option<&String>,
) -> String {
format!("{}{}A", cur_str, prefix)
}
}

1004
src/fs/explorer/mod.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -23,17 +23,11 @@
*
*/
extern crate bytesize;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
extern crate users;
use crate::utils::{fmt_pex, time_to_str};
use bytesize::ByteSize;
// Mod
pub mod explorer;
// Ext
use std::path::PathBuf;
use std::time::SystemTime;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
use users::get_user_by_uid;
/// ## FsEntry
///
@@ -97,10 +91,10 @@ impl FsEntry {
/// ### get_name
///
/// Get file name from `FsEntry`
pub fn get_name(&self) -> String {
pub fn get_name(&self) -> &'_ str {
match self {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
FsEntry::Directory(dir) => dir.name.as_ref(),
FsEntry::File(file) => file.name.as_ref(),
}
}
@@ -201,6 +195,20 @@ impl FsEntry {
matches!(self, FsEntry::Directory(_))
}
/// ### is_file
///
/// Returns whether a FsEntry is a File
pub fn is_file(&self) -> bool {
matches!(self, FsEntry::File(_))
}
/// ### is_hidden
///
/// Returns whether FsEntry is hidden
pub fn is_hidden(&self) -> bool {
self.get_name().starts_with('.')
}
/// ### get_realfile
///
/// Return the real file pointed by a `FsEntry`
@@ -218,67 +226,6 @@ impl FsEntry {
}
}
impl std::fmt::Display for FsEntry {
/// ### fmt_ls
///
/// Format File Entry as `ls` does
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
// Create mode string
let mut mode: String = String::with_capacity(10);
let file_type: char = match self.is_symlink() {
true => 'l',
false => match self.is_dir() {
true => 'd',
false => '-',
},
};
mode.push(file_type);
match self.get_unix_pex() {
None => mode.push_str("?????????"),
Some((owner, group, others)) => mode.push_str(fmt_pex(owner, group, others).as_str()),
}
// Get username
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
let username: String = match self.get_user() {
Some(uid) => match get_user_by_uid(uid) {
Some(user) => user.name().to_string_lossy().to_string(),
None => uid.to_string(),
},
None => String::from("0"),
};
#[cfg(target_os = "windows")]
let username: usize = match self.get_user() {
Some(uid) => uid as usize,
None => 0,
};
// Get group
/*
let group: String = match self.get_group() {
Some(gid) => match get_group_by_gid(gid) {
Some(group) => group.name().to_string_lossy().to_string(),
None => gid.to_string(),
},
None => String::from("0"),
};
*/
// Get byte size
let size: ByteSize = ByteSize(self.get_size() as u64);
// Get date
let datetime: String = time_to_str(self.get_last_change_time(), "%b %d %Y %H:%M");
// Set file name (or elide if too long)
let name: String = self.get_name();
let name: String = match name.len() >= 24 {
false => name,
true => format!("{}...", &name.as_str()[0..20]),
};
write!(
f,
"{:24}\t{:12}\t{:12}\t{:9}\t{:17}",
name, mode, username, size, datetime
)
}
}
#[cfg(test)]
mod tests {
@@ -310,6 +257,7 @@ mod tests {
assert_eq!(entry.get_group(), Some(0));
assert_eq!(entry.is_symlink(), false);
assert_eq!(entry.is_dir(), true);
assert_eq!(entry.is_file(), false);
assert_eq!(entry.get_unix_pex(), Some((7, 5, 5)));
}
@@ -342,6 +290,55 @@ mod tests {
assert_eq!(entry.get_unix_pex(), Some((6, 4, 4)));
assert_eq!(entry.is_symlink(), false);
assert_eq!(entry.is_dir(), false);
assert_eq!(entry.is_file(), true);
}
#[test]
fn test_fs_fsentry_hidden_files() {
let t_now: SystemTime = SystemTime::now();
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from("bar.txt"),
abs_path: PathBuf::from("/bar.txt"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
assert_eq!(entry.is_hidden(), false);
let entry: FsEntry = FsEntry::File(FsFile {
name: String::from(".gitignore"),
abs_path: PathBuf::from("/.gitignore"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
size: 8192,
readonly: false,
ftype: Some(String::from("txt")),
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((6, 4, 4)), // UNIX only
});
assert_eq!(entry.is_hidden(), true);
let entry: FsEntry = FsEntry::Directory(FsDirectory {
name: String::from(".git"),
abs_path: PathBuf::from("/.git"),
last_change_time: t_now,
last_access_time: t_now,
creation_time: t_now,
readonly: false,
symlink: None, // UNIX only
user: Some(0), // UNIX only
group: Some(0), // UNIX only
unix_pex: Some((7, 5, 5)), // UNIX only
});
assert_eq!(entry.is_hidden(), true);
}
#[test]

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -28,7 +28,9 @@ use std::path::{Path, PathBuf};
use std::time::SystemTime;
// Metadata ext
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
use std::os::unix::fs::MetadataExt;
use std::fs::set_permissions;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
use std::os::unix::fs::{MetadataExt, PermissionsExt};
// Locals
use crate::fs::{FsDirectory, FsEntry, FsFile};
@@ -74,7 +76,7 @@ impl std::fmt::Display for HostError {
HostErrorType::NoSuchFileOrDirectory => "No such file or directory",
HostErrorType::ReadonlyFile => "File is readonly",
HostErrorType::DirNotAccessible => "Could not access directory",
HostErrorType::FileNotAccessible => "Could not access directory",
HostErrorType::FileNotAccessible => "Could not access file",
HostErrorType::FileAlreadyExists => "File already exists",
HostErrorType::CouldNotCreateFile => "Could not create file",
HostErrorType::DeleteFailed => "Could not delete file",
@@ -134,7 +136,8 @@ impl Localhost {
/// ### change_wrkdir
///
/// Change working directory with the new provided directory
pub fn change_wrkdir(&mut self, new_dir: PathBuf) -> Result<PathBuf, HostError> {
pub fn change_wrkdir(&mut self, new_dir: &Path) -> Result<PathBuf, HostError> {
let new_dir: PathBuf = self.to_abs_path(new_dir);
// Check whether directory exists
if !self.file_exists(new_dir.as_path()) {
return Err(HostError::new(HostErrorType::NoSuchFileOrDirectory, None));
@@ -166,14 +169,7 @@ impl Localhost {
/// Extended option version of makedir.
/// ignex: don't report error if directory already exists
pub fn mkdir_ex(&mut self, dir_name: &Path, ignex: bool) -> Result<(), HostError> {
let dir_path: PathBuf = match dir_name.is_absolute() {
true => PathBuf::from(dir_name),
false => {
let mut dir_path: PathBuf = self.wrkdir.clone();
dir_path.push(dir_name);
dir_path
}
};
let dir_path: PathBuf = self.to_abs_path(dir_name);
// If dir already exists, return Error
if dir_path.exists() {
match ignex {
@@ -185,10 +181,7 @@ impl Localhost {
Ok(_) => {
// Update dir
if dir_name.is_relative() {
self.files = match self.scan_dir(self.wrkdir.as_path()) {
Ok(f) => f,
Err(err) => return Err(err),
};
self.files = self.scan_dir(self.wrkdir.as_path())?;
}
Ok(())
}
@@ -210,10 +203,7 @@ impl Localhost {
match std::fs::remove_dir_all(dir.abs_path.as_path()) {
Ok(_) => {
// Update dir
self.files = match self.scan_dir(self.wrkdir.as_path()) {
Ok(f) => f,
Err(err) => return Err(err),
};
self.files = self.scan_dir(self.wrkdir.as_path())?;
Ok(())
}
Err(err) => Err(HostError::new(HostErrorType::DeleteFailed, Some(err))),
@@ -228,10 +218,7 @@ impl Localhost {
match std::fs::remove_file(file.abs_path.as_path()) {
Ok(_) => {
// Update dir
self.files = match self.scan_dir(self.wrkdir.as_path()) {
Ok(f) => f,
Err(err) => return Err(err),
};
self.files = self.scan_dir(self.wrkdir.as_path())?;
Ok(())
}
Err(err) => Err(HostError::new(HostErrorType::DeleteFailed, Some(err))),
@@ -248,22 +235,85 @@ impl Localhost {
match std::fs::rename(abs_path.as_path(), dst_path) {
Ok(_) => {
// Scan dir
self.files = match self.scan_dir(self.wrkdir.as_path()) {
Ok(f) => f,
Err(err) => return Err(err),
};
self.files = self.scan_dir(self.wrkdir.as_path())?;
Ok(())
}
Err(err) => Err(HostError::new(HostErrorType::CouldNotCreateFile, Some(err))),
}
}
/// ### copy
///
/// Copy file to destination path
pub fn copy(&mut self, entry: &FsEntry, dst: &Path) -> Result<(), HostError> {
// Get absolute path of dest
let dst: PathBuf = self.to_abs_path(dst);
// Match entry
match entry {
FsEntry::File(file) => {
// Copy file
// If destination path is a directory, push file name
let dst: PathBuf = match dst.as_path().is_dir() {
true => {
let mut p: PathBuf = dst.clone();
p.push(file.name.as_str());
p
}
false => dst.clone(),
};
// Copy entry path to dst path
if let Err(err) = std::fs::copy(file.abs_path.as_path(), dst.as_path()) {
return Err(HostError::new(HostErrorType::CouldNotCreateFile, Some(err)));
}
}
FsEntry::Directory(dir) => {
// If destination path doesn't exist, create destination
if !dst.exists() {
self.mkdir(dst.as_path())?;
}
// Scan dir
let dir_files: Vec<FsEntry> = self.scan_dir(dir.abs_path.as_path())?;
// Iterate files
for dir_entry in dir_files.iter() {
// Calculate dst
let mut sub_dst: PathBuf = dst.clone();
sub_dst.push(dir_entry.get_name());
// Call function recursively
self.copy(dir_entry, sub_dst.as_path())?;
}
}
}
// Reload directory if dst is pwd
match dst.is_dir() {
true => {
if dst == self.pwd().as_path() {
self.files = self.scan_dir(self.wrkdir.as_path())?;
} else if let Some(parent) = dst.parent() {
// If parent is pwd, scan directory
if parent == self.pwd().as_path() {
self.files = self.scan_dir(self.wrkdir.as_path())?;
}
}
}
false => {
if let Some(parent) = dst.parent() {
// If parent is pwd, scan directory
if parent == self.pwd().as_path() {
self.files = self.scan_dir(self.wrkdir.as_path())?;
}
}
}
}
Ok(())
}
/// ### stat
///
/// Stat file and create a FsEntry
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
let attr: Metadata = match fs::metadata(path) {
let path: PathBuf = self.to_abs_path(path);
let attr: Metadata = match fs::metadata(path.as_path()) {
Ok(metadata) => metadata,
Err(err) => return Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
};
@@ -272,12 +322,12 @@ impl Localhost {
Ok(match path.is_dir() {
true => FsEntry::Directory(FsDirectory {
name: file_name,
abs_path: PathBuf::from(path),
abs_path: path.clone(),
last_change_time: attr.modified().unwrap_or(SystemTime::UNIX_EPOCH),
last_access_time: attr.accessed().unwrap_or(SystemTime::UNIX_EPOCH),
creation_time: attr.created().unwrap_or(SystemTime::UNIX_EPOCH),
readonly: attr.permissions().readonly(),
symlink: match fs::read_link(path) {
symlink: match fs::read_link(path.as_path()) {
Ok(p) => match self.stat(p.as_path()) {
Ok(entry) => Some(Box::new(entry)),
Err(_) => None,
@@ -296,14 +346,14 @@ impl Localhost {
};
FsEntry::File(FsFile {
name: file_name,
abs_path: PathBuf::from(path),
abs_path: path.clone(),
last_change_time: attr.modified().unwrap_or(SystemTime::UNIX_EPOCH),
last_access_time: attr.accessed().unwrap_or(SystemTime::UNIX_EPOCH),
creation_time: attr.created().unwrap_or(SystemTime::UNIX_EPOCH),
readonly: attr.permissions().readonly(),
size: attr.len() as usize,
ftype: extension,
symlink: match fs::read_link(path) {
symlink: match fs::read_link(path.as_path()) {
Ok(p) => match self.stat(p.as_path()) {
Ok(entry) => Some(Box::new(entry)),
Err(_) => None,
@@ -324,7 +374,8 @@ impl Localhost {
#[cfg(target_os = "windows")]
#[cfg(not(tarpaulin_include))]
pub fn stat(&self, path: &Path) -> Result<FsEntry, HostError> {
let attr: Metadata = match fs::metadata(path.clone()) {
let path: PathBuf = self.to_abs_path(path);
let attr: Metadata = match fs::metadata(path.as_path()) {
Ok(metadata) => metadata,
Err(err) => return Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
};
@@ -333,12 +384,12 @@ impl Localhost {
Ok(match path.is_dir() {
true => FsEntry::Directory(FsDirectory {
name: file_name,
abs_path: PathBuf::from(path),
abs_path: path.clone(),
last_change_time: attr.modified().unwrap_or(SystemTime::UNIX_EPOCH),
last_access_time: attr.accessed().unwrap_or(SystemTime::UNIX_EPOCH),
creation_time: attr.created().unwrap_or(SystemTime::UNIX_EPOCH),
readonly: attr.permissions().readonly(),
symlink: match fs::read_link(path) {
symlink: match fs::read_link(path.as_path()) {
Ok(p) => match self.stat(p.as_path()) {
Ok(entry) => Some(Box::new(entry)),
Err(_) => None, // Ignore errors
@@ -357,14 +408,14 @@ impl Localhost {
};
FsEntry::File(FsFile {
name: file_name,
abs_path: PathBuf::from(path),
abs_path: path.clone(),
last_change_time: attr.modified().unwrap_or(SystemTime::UNIX_EPOCH),
last_access_time: attr.accessed().unwrap_or(SystemTime::UNIX_EPOCH),
creation_time: attr.created().unwrap_or(SystemTime::UNIX_EPOCH),
readonly: attr.permissions().readonly(),
size: attr.len() as usize,
ftype: extension,
symlink: match fs::read_link(path) {
symlink: match fs::read_link(path.as_path()) {
Ok(p) => match self.stat(p.as_path()) {
Ok(entry) => Some(Box::new(entry)),
Err(_) => None,
@@ -379,18 +430,39 @@ impl Localhost {
})
}
/// ### chmod
///
/// Change file mode to file, according to UNIX permissions
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
pub fn chmod(&self, path: &Path, pex: (u8, u8, u8)) -> Result<(), HostError> {
let path: PathBuf = self.to_abs_path(path);
// Get metadta
match fs::metadata(path.as_path()) {
Ok(metadata) => {
let mut mpex = metadata.permissions();
mpex.set_mode(self.mode_to_u32(pex));
match set_permissions(path.as_path(), mpex) {
Ok(_) => Ok(()),
Err(err) => Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
}
}
Err(err) => Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
}
}
/// ### open_file_read
///
/// Open file for read
pub fn open_file_read(&self, file: &Path) -> Result<File, HostError> {
if !self.file_exists(file) {
let file: PathBuf = self.to_abs_path(file);
if !self.file_exists(file.as_path()) {
return Err(HostError::new(HostErrorType::NoSuchFileOrDirectory, None));
}
match OpenOptions::new()
.create(false)
.read(true)
.write(false)
.open(file)
.open(file.as_path())
{
Ok(f) => Ok(f),
Err(err) => Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
@@ -401,14 +473,15 @@ impl Localhost {
///
/// Open file for write
pub fn open_file_write(&self, file: &Path) -> Result<File, HostError> {
let file: PathBuf = self.to_abs_path(file);
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(file)
.open(file.as_path())
{
Ok(f) => Ok(f),
Err(err) => match self.file_exists(file) {
Err(err) => match self.file_exists(file.as_path()) {
true => Err(HostError::new(HostErrorType::ReadonlyFile, Some(err))),
false => Err(HostError::new(HostErrorType::FileNotAccessible, Some(err))),
},
@@ -418,7 +491,7 @@ impl Localhost {
/// ### file_exists
///
/// Returns whether provided file path exists
fn file_exists(&self, path: &Path) -> bool {
pub fn file_exists(&self, path: &Path) -> bool {
path.exists()
}
@@ -452,6 +525,29 @@ impl Localhost {
let others: u8 = (mode & 0x7) as u8;
(user, group, others)
}
/// mode_to_u32
///
/// Convert owner,group,others to u32
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
fn mode_to_u32(&self, mode: (u8, u8, u8)) -> u32 {
((mode.0 as u32) << 6) + ((mode.1 as u32) << 3) + mode.2 as u32
}
/// ### to_abs_path
///
/// Convert path to absolute path
fn to_abs_path(&self, p: &Path) -> PathBuf {
// Convert to abs path
match p.is_relative() {
true => {
let mut path: PathBuf = self.wrkdir.clone();
path.push(p);
path
}
false => PathBuf::from(p),
}
}
}
#[cfg(test)]
@@ -534,7 +630,7 @@ mod tests {
fn test_host_localhost_change_dir() {
let mut host: Localhost = Localhost::new(PathBuf::from("/dev")).ok().unwrap();
let new_dir: PathBuf = PathBuf::from("/dev");
assert!(host.change_wrkdir(new_dir.clone()).is_ok());
assert!(host.change_wrkdir(new_dir.as_path()).is_ok());
// Verify new files
// Scan dir
let entries = std::fs::read_dir(PathBuf::from(new_dir).as_path()).unwrap();
@@ -551,7 +647,7 @@ mod tests {
fn test_host_localhost_change_dir_failed() {
let mut host: Localhost = Localhost::new(PathBuf::from("/bin")).ok().unwrap();
let new_dir: PathBuf = PathBuf::from("/omar/gabber/123/456");
assert!(host.change_wrkdir(new_dir.clone()).is_ok());
assert!(host.change_wrkdir(new_dir.as_path()).is_ok());
}
#[test]
@@ -720,6 +816,171 @@ mod tests {
.is_err());
}
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
#[test]
fn test_host_chmod() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
let file: tempfile::NamedTempFile = create_sample_file();
let host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
// mode_to_u32
assert_eq!(host.mode_to_u32((6, 4, 4)), 0o644);
assert_eq!(host.mode_to_u32((7, 7, 5)), 0o775);
// Chmod to file
assert!(host.chmod(file.path(), (7, 7, 5)).is_ok());
// Chmod to dir
assert!(host.chmod(tmpdir.path(), (7, 5, 0)).is_ok());
// Error
assert!(host
.chmod(Path::new("/tmp/krgiogoiegj/kwrgnoerig"), (7, 7, 7))
.is_err());
}
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
#[test]
fn test_host_copy_file_absolute() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
// Create file in tmpdir
let mut file1_path: PathBuf = PathBuf::from(tmpdir.path());
file1_path.push("foo.txt");
// Write file 1
let mut file1: File = File::create(file1_path.as_path()).ok().unwrap();
assert!(file1.write_all(b"Hello world!\n").is_ok());
// Get file 2 path
let mut file2_path: PathBuf = PathBuf::from(tmpdir.path());
file2_path.push("bar.txt");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let file1_entry: FsEntry = host.files.get(0).unwrap().clone();
assert_eq!(file1_entry.get_name(), String::from("foo.txt"));
// Copy
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
// Verify host has two files
assert_eq!(host.files.len(), 2);
}
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
#[test]
fn test_host_copy_file_relative() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
// Create file in tmpdir
let mut file1_path: PathBuf = PathBuf::from(tmpdir.path());
file1_path.push("foo.txt");
// Write file 1
let mut file1: File = File::create(file1_path.as_path()).ok().unwrap();
assert!(file1.write_all(b"Hello world!\n").is_ok());
// Get file 2 path
let file2_path: PathBuf = PathBuf::from("bar.txt");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let file1_entry: FsEntry = host.files.get(0).unwrap().clone();
assert_eq!(file1_entry.get_name(), String::from("foo.txt"));
// Copy
assert!(host.copy(&file1_entry, file2_path.as_path()).is_ok());
// Verify host has two files
assert_eq!(host.files.len(), 2);
}
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
#[test]
fn test_host_copy_directory_absolute() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
// Create directory in tmpdir
let mut dir_src: PathBuf = PathBuf::from(tmpdir.path());
dir_src.push("test_dir/");
assert!(std::fs::create_dir(dir_src.as_path()).is_ok());
// Create file in src dir
let mut file1_path: PathBuf = dir_src.clone();
file1_path.push("foo.txt");
// Write file 1
let mut file1: File = File::create(file1_path.as_path()).ok().unwrap();
assert!(file1.write_all(b"Hello world!\n").is_ok());
// Copy dir src to dir ddest
let mut dir_dest: PathBuf = PathBuf::from(tmpdir.path());
dir_dest.push("test_dest_dir/");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let dir_src_entry: FsEntry = host.files.get(0).unwrap().clone();
assert_eq!(dir_src_entry.get_name(), String::from("test_dir"));
// Copy
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
// Verify host has two files
assert_eq!(host.files.len(), 2);
// Verify dir_dest contains foo.txt
let mut test_file_path: PathBuf = dir_dest.clone();
test_file_path.push("foo.txt");
assert!(host.stat(test_file_path.as_path()).is_ok());
}
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
#[test]
fn test_host_copy_directory_relative() {
let tmpdir: tempfile::TempDir = tempfile::TempDir::new().unwrap();
// Create directory in tmpdir
let mut dir_src: PathBuf = PathBuf::from(tmpdir.path());
dir_src.push("test_dir/");
assert!(std::fs::create_dir(dir_src.as_path()).is_ok());
// Create file in src dir
let mut file1_path: PathBuf = dir_src.clone();
file1_path.push("foo.txt");
// Write file 1
let mut file1: File = File::create(file1_path.as_path()).ok().unwrap();
assert!(file1.write_all(b"Hello world!\n").is_ok());
// Copy dir src to dir ddest
let dir_dest: PathBuf = PathBuf::from("test_dest_dir/");
// Create host
let mut host: Localhost = Localhost::new(PathBuf::from(tmpdir.path())).ok().unwrap();
let dir_src_entry: FsEntry = host.files.get(0).unwrap().clone();
assert_eq!(dir_src_entry.get_name(), String::from("test_dir"));
// Copy
assert!(host.copy(&dir_src_entry, dir_dest.as_path()).is_ok());
// Verify host has two files
assert_eq!(host.files.len(), 2);
// Verify dir_dest contains foo.txt
let mut test_file_path: PathBuf = dir_dest.clone();
test_file_path.push("foo.txt");
println!("{:?}", host.scan_dir(tmpdir.path()).ok().unwrap());
assert!(host.stat(test_file_path.as_path()).is_ok());
}
#[test]
fn test_host_fmt_error() {
let err: HostError = HostError::new(
HostErrorType::CouldNotCreateFile,
Some(std::io::Error::from(std::io::ErrorKind::AddrInUse)),
);
assert_eq!(
format!("{}", err),
String::from("Could not create file: address in use")
);
assert_eq!(
format!("{}", HostError::new(HostErrorType::DeleteFailed, None)),
String::from("Could not delete file")
);
assert_eq!(
format!("{}", HostError::new(HostErrorType::DirNotAccessible, None)),
String::from("Could not access directory")
);
assert_eq!(
format!(
"{}",
HostError::new(HostErrorType::NoSuchFileOrDirectory, None)
),
String::from("No such file or directory")
);
assert_eq!(
format!("{}", HostError::new(HostErrorType::ReadonlyFile, None)),
String::from("File is readonly")
);
assert_eq!(
format!("{}", HostError::new(HostErrorType::FileNotAccessible, None)),
String::from("Could not access file")
);
assert_eq!(
format!("{}", HostError::new(HostErrorType::FileAlreadyExists, None)),
String::from("File already exists")
);
}
/// ### create_sample_file
///
/// Create a sample file

View File

@@ -1,6 +1,6 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -19,11 +19,19 @@
*
*/
#[macro_use] extern crate lazy_static;
#[macro_use]
extern crate bitflags;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate magic_crypt;
pub mod activity_manager;
pub mod bookmarks;
pub mod config;
pub mod filetransfer;
pub mod fs;
pub mod host;
pub mod system;
pub mod ui;
pub mod utils;

View File

@@ -1,6 +1,6 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -25,7 +25,11 @@ const TERMSCP_AUTHORS: &str = env!("CARGO_PKG_AUTHORS");
// Crates
extern crate getopts;
#[macro_use]
extern crate bitflags;
#[macro_use]
extern crate lazy_static;
#[macro_use]
extern crate magic_crypt;
extern crate rpassword;
// External libs
@@ -36,9 +40,12 @@ use std::time::Duration;
// Include
mod activity_manager;
mod bookmarks;
mod config;
mod filetransfer;
mod fs;
mod host;
mod system;
mod ui;
mod utils;
@@ -51,9 +58,11 @@ use filetransfer::FileTransferProtocol;
/// Print usage
fn print_usage(opts: Options) {
let brief = String::from("Usage: termscp [options]... [protocol://user@address:port]");
let brief = String::from(
"Usage: termscp [options]... [protocol://user@address:port:wrkdir] [local-wrkdir]",
);
print!("{}", opts.usage(&brief));
println!("\nPlease, report issues to <https://github.com/ChristianVisintin/TermSCP>");
println!("\nPlease, report issues to <https://github.com/veeso/termscp>");
}
fn main() {
@@ -63,6 +72,7 @@ fn main() {
let mut port: u16 = 22; // Default port
let mut username: Option<String> = None; // Default username
let mut password: Option<String> = None; // Default password
let mut remote_wrkdir: Option<PathBuf> = None;
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp; // Default protocol
let mut ticks: Duration = Duration::from_millis(10);
//Process options
@@ -113,15 +123,17 @@ fn main() {
}
// Check free args
let extra_args: Vec<String> = matches.free;
// Remote argument
if let Some(remote) = extra_args.get(0) {
// Parse address
match utils::parse_remote_opt(remote) {
Ok((addr, portn, proto, user)) => {
match utils::parser::parse_remote_opt(remote) {
Ok(host_opts) => {
// Set params
address = Some(addr);
port = portn;
protocol = proto;
username = user;
address = Some(host_opts.hostname);
port = host_opts.port;
protocol = host_opts.protocol;
username = host_opts.username;
remote_wrkdir = host_opts.wrkdir;
}
Err(err) => {
eprintln!("Bad address option: {}", err);
@@ -130,6 +142,15 @@ fn main() {
}
}
}
// Local directory
if let Some(localdir) = extra_args.get(1) {
// Change working directory if local dir is set
let localdir: PathBuf = PathBuf::from(localdir);
if let Err(err) = env::set_current_dir(localdir.as_path()) {
eprintln!("Bad working directory argument: {}", err);
std::process::exit(255);
}
}
// Get working directory
let wrkdir: PathBuf = match env::current_dir() {
Ok(dir) => dir,
@@ -160,14 +181,14 @@ fn main() {
// Create activity manager (and context too)
let mut manager: ActivityManager = match ActivityManager::new(&wrkdir, ticks) {
Ok(m) => m,
Err(_) => {
eprintln!("Invalid directory '{}'", wrkdir.display());
Err(err) => {
eprintln!("Could not start activity manager: {}", err);
std::process::exit(255);
}
};
// Set file transfer params if set
if let Some(address) = address {
manager.set_filetransfer_params(address, port, protocol, username, password);
manager.set_filetransfer_params(address, port, protocol, username, password, remote_wrkdir);
}
// Run
manager.run(start_activity);

View File

@@ -0,0 +1,668 @@
//! ## BookmarksClient
//!
//! `bookmarks_client` is the module which provides an API between the Bookmarks module and the system
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate whoami;
// Crate
#[cfg(any(target_os = "windows", target_os = "macos"))]
use super::keys::keyringstorage::KeyringStorage;
use super::keys::{filestorage::FileStorage, KeyStorage, KeyStorageError};
// Local
use crate::bookmarks::serializer::BookmarkSerializer;
use crate::bookmarks::{Bookmark, SerializerError, SerializerErrorKind, UserHosts};
use crate::filetransfer::FileTransferProtocol;
use crate::utils::crypto;
use crate::utils::fmt::fmt_time;
use crate::utils::random::random_alphanumeric_with_len;
// Ext
use std::fs::OpenOptions;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::string::ToString;
use std::time::SystemTime;
/// ## BookmarksClient
///
/// BookmarksClient provides a layer between the host system and the bookmarks module
pub struct BookmarksClient {
hosts: UserHosts,
bookmarks_file: PathBuf,
key: String,
recents_size: usize,
}
impl BookmarksClient {
/// ### BookmarksClient
///
/// Instantiates a new BookmarksClient
/// Bookmarks file path must be provided
/// Storage path for file provider must be provided
pub fn new(
bookmarks_file: &Path,
storage_path: &Path,
recents_size: usize,
) -> Result<BookmarksClient, SerializerError> {
// Create default hosts
let default_hosts: UserHosts = Default::default();
// Make a key storage (windows / macos)
#[cfg(any(target_os = "windows", target_os = "macos"))]
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
let username: String = whoami::username();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
// Check if keyring storage is supported
#[cfg(not(test))]
let app_name: &str = "termscp";
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "termscp-test";
match storage.is_supported() {
true => (Box::new(storage), app_name),
false => (Box::new(FileStorage::new(storage_path)), "bookmarks"),
}
};
// Make a key storage (linux / unix)
#[cfg(any(target_os = "linux", target_os = "unix"))]
let (key_storage, service_id): (Box<dyn KeyStorage>, &str) = {
#[cfg(not(test))]
let app_name: &str = "bookmarks";
#[cfg(test)] // NOTE: when running test, add -test
let app_name: &str = "bookmarks-test";
(Box::new(FileStorage::new(storage_path)), app_name)
};
// Load key
let key: String = match key_storage.get_key(service_id) {
Ok(k) => k,
Err(e) => match e {
KeyStorageError::NoSuchKey => {
// If no such key, generate key and set it into the storage
let key: String = Self::generate_key();
if let Err(e) = key_storage.set_key(service_id, key.as_str()) {
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!("Could not write key to storage: {}", e),
));
}
// Return key
key
}
_ => {
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!("Could not get key from storage: {}", e),
))
}
},
};
let mut client: BookmarksClient = BookmarksClient {
hosts: default_hosts,
bookmarks_file: PathBuf::from(bookmarks_file),
key,
recents_size,
};
// If bookmark file doesn't exist, initialize it
if !bookmarks_file.exists() {
if let Err(err) = client.write_bookmarks() {
return Err(err);
}
} else {
// Load bookmarks from file
if let Err(err) = client.read_bookmarks() {
return Err(err);
}
}
// Load key
Ok(client)
}
/// ### iter_bookmarks
///
/// Iterate over bookmarks keys
pub fn iter_bookmarks(&self) -> impl Iterator<Item = &String> + '_ {
Box::new(self.hosts.bookmarks.keys())
}
/// ### get_bookmark
///
/// Get bookmark associated to key
pub fn get_bookmark(
&self,
key: &str,
) -> Option<(String, u16, FileTransferProtocol, String, Option<String>)> {
let entry: &Bookmark = self.hosts.bookmarks.get(key)?;
Some((
entry.address.clone(),
entry.port,
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
Ok(proto) => proto,
Err(_) => FileTransferProtocol::Sftp, // Default
},
entry.username.clone(),
match &entry.password {
// Decrypted password if Some; if decryption fails return None
Some(pwd) => match self.decrypt_str(pwd.as_str()) {
Ok(decrypted_pwd) => Some(decrypted_pwd),
Err(_) => None,
},
None => None,
},
))
}
/// ### add_recent
///
/// Add a new recent to bookmarks
pub fn add_bookmark(
&mut self,
name: String,
addr: String,
port: u16,
protocol: FileTransferProtocol,
username: String,
password: Option<String>,
) {
if name.is_empty() {
panic!("Bookmark name can't be empty");
}
// Make bookmark
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, password);
self.hosts.bookmarks.insert(name, host);
}
/// ### del_bookmark
///
/// Delete entry from bookmarks
pub fn del_bookmark(&mut self, name: &str) {
let _ = self.hosts.bookmarks.remove(name);
}
/// ### iter_recents
///
/// Iterate over recents keys
pub fn iter_recents(&self) -> impl Iterator<Item = &String> + '_ {
Box::new(self.hosts.recents.keys())
}
/// ### get_recent
///
/// Get recent associated to key
pub fn get_recent(&self, key: &str) -> Option<(String, u16, FileTransferProtocol, String)> {
// NOTE: password is not decrypted; recents will never have password
let entry: &Bookmark = self.hosts.recents.get(key)?;
Some((
entry.address.clone(),
entry.port,
match FileTransferProtocol::from_str(entry.protocol.as_str()) {
Ok(proto) => proto,
Err(_) => FileTransferProtocol::Sftp, // Default
},
entry.username.clone(),
))
}
/// ### add_recent
///
/// Add a new recent to bookmarks
pub fn add_recent(
&mut self,
addr: String,
port: u16,
protocol: FileTransferProtocol,
username: String,
) {
// Make bookmark
let host: Bookmark = self.make_bookmark(addr, port, protocol, username, None);
// Check if duplicated
for recent_host in self.hosts.recents.values() {
if *recent_host == host {
// Don't save duplicates
return;
}
}
// If hosts size is bigger than self.recents_size; pop last
if self.hosts.recents.len() >= self.recents_size {
// Get keys
let mut keys: Vec<String> = Vec::with_capacity(self.hosts.recents.len());
for key in self.hosts.recents.keys() {
keys.push(key.clone());
}
// Sort keys; NOTE: most recent is the last element
keys.sort();
// Delete keys starting from the last one
for key in keys.iter() {
let _ = self.hosts.recents.remove(key);
// If length is < self.recents_size; break
if self.hosts.recents.len() < self.recents_size {
break;
}
}
}
let name: String = fmt_time(SystemTime::now(), "ISO%Y%m%dT%H%M%S");
self.hosts.recents.insert(name, host);
}
/// ### del_recent
///
/// Delete entry from recents
pub fn del_recent(&mut self, name: &str) {
let _ = self.hosts.recents.remove(name);
}
/// ### write_bookmarks
///
/// Write bookmarks to file
pub fn write_bookmarks(&self) -> Result<(), SerializerError> {
// Open file
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.bookmarks_file.as_path())
{
Ok(writer) => {
let serializer: BookmarkSerializer = BookmarkSerializer {};
serializer.serialize(Box::new(writer), &self.hosts)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### read_bookmarks
///
/// Read bookmarks from file
fn read_bookmarks(&mut self) -> Result<(), SerializerError> {
// Open bookmarks file for read
match OpenOptions::new()
.read(true)
.open(self.bookmarks_file.as_path())
{
Ok(reader) => {
// Deserialize
let deserializer: BookmarkSerializer = BookmarkSerializer {};
match deserializer.deserialize(Box::new(reader)) {
Ok(hosts) => {
self.hosts = hosts;
Ok(())
}
Err(err) => Err(err),
}
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### generate_key
///
/// Generate a new AES key
fn generate_key() -> String {
// Generate 256 bytes (2048 bits) key
random_alphanumeric_with_len(256)
}
/// ### make_bookmark
///
/// Make bookmark from credentials
fn make_bookmark(
&self,
addr: String,
port: u16,
protocol: FileTransferProtocol,
username: String,
password: Option<String>,
) -> Bookmark {
Bookmark {
address: addr,
port,
username,
protocol: protocol.to_string(),
password: match password {
Some(p) => Some(self.encrypt_str(p.as_str())), // Encrypt password if provided
None => None,
},
}
}
/// ### encrypt_str
///
/// Encrypt provided string using AES-128. Encrypted buffer is then converted to BASE64
fn encrypt_str(&self, txt: &str) -> String {
crypto::aes128_b64_crypt(self.key.as_str(), txt)
}
/// ### decrypt_str
///
/// Decrypt provided string using AES-128
fn decrypt_str(&self, secret: &str) -> Result<String, SerializerError> {
match crypto::aes128_b64_decrypt(self.key.as_str(), secret) {
Ok(txt) => Ok(txt),
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::SyntaxError,
err.to_string(),
)),
}
}
}
#[cfg(test)]
#[cfg(not(target_os = "macos"))] // CI/CD blocks
mod tests {
use super::*;
use std::thread::sleep;
use std::time::Duration;
#[test]
fn test_system_bookmarks_new() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Verify client
assert_eq!(client.hosts.bookmarks.len(), 0);
assert_eq!(client.hosts.recents.len(), 0);
assert_eq!(client.key.len(), 256);
assert_eq!(client.bookmarks_file, cfg_path);
assert_eq!(client.recents_size, 16);
}
#[test]
#[cfg(any(target_os = "unix", target_os = "linux"))]
fn test_system_bookmarks_new_err() {
assert!(BookmarksClient::new(
Path::new("/tmp/oifoif/omar"),
Path::new("/tmp/efnnu/omar"),
16
)
.is_err());
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
assert!(
BookmarksClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar"), 16).is_err()
);
}
#[test]
fn test_system_bookmarks_new_from_existing() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add some bookmarks
client.add_bookmark(
String::from("raspberry"),
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
Some(String::from("mypassword")),
);
client.add_recent(
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
let recent_key: String = String::from(client.iter_recents().next().unwrap());
assert!(client.write_bookmarks().is_ok());
let key: String = client.key.clone();
// Re-initialize a client
let client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Verify it loaded parameters correctly
assert_eq!(client.key, key);
let bookmark: (String, u16, FileTransferProtocol, String, Option<String>) =
client.get_bookmark(&String::from("raspberry")).unwrap();
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
let bookmark: (String, u16, FileTransferProtocol, String) =
client.get_recent(&recent_key).unwrap();
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
}
#[test]
fn test_system_bookmarks_manipulate_bookmarks() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
String::from("raspberry"),
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
Some(String::from("mypassword")),
);
client.add_bookmark(
String::from("raspberry2"),
String::from("192.168.1.32"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
Some(String::from("mypassword2")),
);
// Iter
assert_eq!(client.iter_bookmarks().count(), 2);
// Get bookmark
let bookmark: (String, u16, FileTransferProtocol, String, Option<String>) =
client.get_bookmark(&String::from("raspberry")).unwrap();
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
assert_eq!(*bookmark.4.as_ref().unwrap(), String::from("mypassword"));
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
// Delete bookmark
client.del_bookmark(&String::from("raspberry"));
// Get unexisting bookmark
assert!(client.get_bookmark(&String::from("raspberry")).is_none());
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
}
#[test]
#[should_panic]
fn test_system_bookmarks_bad_bookmark_name() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
String::from(""),
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
Some(String::from("mypassword")),
);
}
#[test]
fn test_system_bookmarks_manipulate_recents() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_recent(
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
// Iter
assert_eq!(client.iter_recents().count(), 1);
let key: String = String::from(client.iter_recents().next().unwrap());
// Get bookmark
let bookmark: (String, u16, FileTransferProtocol, String) =
client.get_recent(&key).unwrap();
assert_eq!(bookmark.0, String::from("192.168.1.31"));
assert_eq!(bookmark.1, 22);
assert_eq!(bookmark.2, FileTransferProtocol::Sftp);
assert_eq!(bookmark.3, String::from("pi"));
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
// Delete bookmark
client.del_recent(&key);
// Get unexisting bookmark
assert!(client.get_bookmark(&key).is_none());
// Write bookmarks
assert!(client.write_bookmarks().is_ok());
}
#[test]
fn test_system_bookmarks_dup_recent() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_recent(
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
client.add_recent(
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
// There should be only one recent
assert_eq!(client.iter_recents().count(), 1);
}
#[test]
fn test_system_bookmarks_recents_more_than_limit() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 2).unwrap();
// Add recent, wait 1 second for each one (cause the name depends on time)
// 1
client.add_recent(
String::from("192.168.1.1"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
sleep(Duration::from_secs(1));
// 2
client.add_recent(
String::from("192.168.1.2"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
sleep(Duration::from_secs(1));
// 3
client.add_recent(
String::from("192.168.1.3"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
);
// Limit is 2
assert_eq!(client.iter_recents().count(), 2);
// Check that 192.168.1.1 has been removed
let key: String = client.iter_recents().nth(0).unwrap().to_string();
assert!(matches!(
client.hosts.recents.get(&key).unwrap().address.as_str(),
"192.168.1.2" | "192.168.1.3"
));
let key: String = client.iter_recents().nth(1).unwrap().to_string();
assert!(matches!(
client.hosts.recents.get(&key).unwrap().address.as_str(),
"192.168.1.2" | "192.168.1.3"
));
}
#[test]
#[should_panic]
fn test_system_bookmarks_add_bookmark_empty() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
// Initialize a new bookmarks client
let mut client: BookmarksClient =
BookmarksClient::new(cfg_path.as_path(), key_path.as_path(), 16).unwrap();
// Add bookmark
client.add_bookmark(
String::from(""),
String::from("192.168.1.31"),
22,
FileTransferProtocol::Sftp,
String::from("pi"),
Some(String::from("mypassword")),
);
}
/// ### get_paths
///
/// Get paths for configuration and key for bookmarks
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let k: PathBuf = PathBuf::from(dir);
let mut c: PathBuf = k.clone();
c.push("bookmarks.toml");
(c, k)
}
/// ### create_tmp_dir
///
/// Create temporary directory
fn create_tmp_dir() -> tempfile::TempDir {
tempfile::TempDir::new().ok().unwrap()
}
}

594
src/system/config_client.rs Normal file
View File

@@ -0,0 +1,594 @@
//! ## ConfigClient
//!
//! `config_client` is the module which provides an API between the Config module and the system
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate rand;
// Locals
use crate::config::serializer::ConfigSerializer;
use crate::config::{SerializerError, SerializerErrorKind, UserConfig};
use crate::filetransfer::FileTransferProtocol;
use crate::fs::explorer::GroupDirs;
// Ext
use std::fs::{create_dir, remove_file, File, OpenOptions};
use std::io::Write;
use std::path::{Path, PathBuf};
use std::str::FromStr;
use std::string::ToString;
// Types
pub type SshHost = (String, String, PathBuf); // 0: host, 1: username, 2: RSA key path
/// ## ConfigClient
///
/// ConfigClient provides a high level API to communicate with the termscp configuration
pub struct ConfigClient {
config: UserConfig, // Configuration loaded
config_path: PathBuf, // Configuration TOML Path
ssh_key_dir: PathBuf, // SSH Key storage directory
}
impl ConfigClient {
/// ### new
///
/// Instantiate a new `ConfigClient` with provided path
pub fn new(config_path: &Path, ssh_key_dir: &Path) -> Result<ConfigClient, SerializerError> {
// Initialize a default configuration
let default_config: UserConfig = UserConfig::default();
// Create client
let mut client: ConfigClient = ConfigClient {
config: default_config,
config_path: PathBuf::from(config_path),
ssh_key_dir: PathBuf::from(ssh_key_dir),
};
// If ssh key directory doesn't exist, create it
if !ssh_key_dir.exists() {
if let Err(err) = create_dir(ssh_key_dir) {
return Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
format!(
"Could not create SSH key directory \"{}\": {}",
ssh_key_dir.display(),
err
),
));
}
}
// If Config file doesn't exist, create it
if !config_path.exists() {
if let Err(err) = client.write_config() {
return Err(err);
}
} else {
// otherwise Load configuration from file
if let Err(err) = client.read_config() {
return Err(err);
}
}
Ok(client)
}
// Text editor
/// ### get_text_editor
///
/// Get text editor from configuration
pub fn get_text_editor(&self) -> PathBuf {
self.config.user_interface.text_editor.clone()
}
/// ### set_text_editor
///
/// Set text editor path
pub fn set_text_editor(&mut self, path: PathBuf) {
self.config.user_interface.text_editor = path;
}
// Default protocol
/// ### get_default_protocol
///
/// Get default protocol from configuration
pub fn get_default_protocol(&self) -> FileTransferProtocol {
match FileTransferProtocol::from_str(self.config.user_interface.default_protocol.as_str()) {
Ok(p) => p,
Err(_) => FileTransferProtocol::Sftp,
}
}
/// ### set_default_protocol
///
/// Set default protocol to configuration
pub fn set_default_protocol(&mut self, proto: FileTransferProtocol) {
self.config.user_interface.default_protocol = proto.to_string();
}
/// ### get_show_hidden_files
///
/// Get value of `show_hidden_files`
pub fn get_show_hidden_files(&self) -> bool {
self.config.user_interface.show_hidden_files
}
/// ### set_show_hidden_files
///
/// Set new value for `show_hidden_files`
pub fn set_show_hidden_files(&mut self, value: bool) {
self.config.user_interface.show_hidden_files = value;
}
/// ### get_check_for_updates
///
/// Get value of `check_for_updates`
pub fn get_check_for_updates(&self) -> bool {
self.config.user_interface.check_for_updates.unwrap_or(true)
}
/// ### set_check_for_updates
///
/// Set new value for `check_for_updates`
pub fn set_check_for_updates(&mut self, value: bool) {
self.config.user_interface.check_for_updates = Some(value);
}
/// ### get_group_dirs
///
/// Get GroupDirs value from configuration (will be converted from string)
pub fn get_group_dirs(&self) -> Option<GroupDirs> {
// Convert string to `GroupDirs`
match &self.config.user_interface.group_dirs {
None => None,
Some(val) => match GroupDirs::from_str(val.as_str()) {
Ok(val) => Some(val),
Err(_) => None,
},
}
}
/// ### set_group_dirs
///
/// Set value for group_dir in configuration.
/// Provided value, if `Some` will be converted to `GroupDirs`
pub fn set_group_dirs(&mut self, val: Option<GroupDirs>) {
self.config.user_interface.group_dirs = match val {
None => None,
Some(val) => Some(val.to_string()),
};
}
/// ### get_file_fmt
///
/// Get current file fmt
pub fn get_file_fmt(&self) -> Option<String> {
self.config.user_interface.file_fmt.clone()
}
/// ### set_file_fmt
///
/// Set file fmt parameter
pub fn set_file_fmt(&mut self, s: String) {
self.config.user_interface.file_fmt = match s.is_empty() {
true => None,
false => Some(s),
};
}
// SSH Keys
/// ### save_ssh_key
///
/// Save a SSH key into configuration.
/// This operation also creates the key file in `ssh_key_dir`
/// and also commits changes to configuration, to prevent incoerent data
pub fn add_ssh_key(
&mut self,
host: &str,
username: &str,
ssh_key: &str,
) -> Result<(), SerializerError> {
let host_name: String = Self::make_ssh_host_key(host, username);
// Get key path
let ssh_key_path: PathBuf = {
let mut p: PathBuf = self.ssh_key_dir.clone();
p.push(format!("{}.key", host_name));
p
};
// Write key to file
let mut f: File = match File::create(ssh_key_path.as_path()) {
Ok(f) => f,
Err(err) => return Self::make_io_err(err),
};
if let Err(err) = f.write_all(ssh_key.as_bytes()) {
return Self::make_io_err(err);
}
// Add host to keys
self.config.remote.ssh_keys.insert(host_name, ssh_key_path);
// Write config
self.write_config()
}
/// ### del_ssh_key
///
/// Delete a ssh key from configuration, using host as key.
/// This operation also unlinks the key file in `ssh_key_dir`
/// and also commits changes to configuration, to prevent incoerent data
pub fn del_ssh_key(&mut self, host: &str, username: &str) -> Result<(), SerializerError> {
// Remove key from configuration and get key path
let key_path: PathBuf = match self
.config
.remote
.ssh_keys
.remove(&Self::make_ssh_host_key(host, username))
{
Some(p) => p,
None => return Ok(()), // Return ok if host doesn't exist
};
// Remove file
if let Err(err) = remove_file(key_path.as_path()) {
return Self::make_io_err(err);
}
// Commit changes to configuration
self.write_config()
}
/// ### get_ssh_key
///
/// Get ssh key from host.
/// None is returned if key doesn't exist
/// `std::io::Error` is returned in case it was not possible to read the key file
pub fn get_ssh_key(&self, mkey: &str) -> std::io::Result<Option<SshHost>> {
// Check if Key exists
match self.config.remote.ssh_keys.get(mkey) {
None => Ok(None),
Some(key_path) => {
// Get host and username
let (host, username): (String, String) = Self::get_ssh_tokens(mkey);
// Return key
Ok(Some((host, username, PathBuf::from(key_path))))
}
}
}
/// ### iter_ssh_keys
///
/// Get an iterator through hosts in the ssh key storage
pub fn iter_ssh_keys(&self) -> impl Iterator<Item = &String> + '_ {
Box::new(self.config.remote.ssh_keys.keys())
}
// I/O
/// ### write_config
///
/// Write configuration to file
pub fn write_config(&self) -> Result<(), SerializerError> {
// Open file
match OpenOptions::new()
.create(true)
.write(true)
.truncate(true)
.open(self.config_path.as_path())
{
Ok(writer) => {
let serializer: ConfigSerializer = ConfigSerializer {};
serializer.serialize(Box::new(writer), &self.config)
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### read_config
///
/// Read configuration from file (or reload it if already read)
pub fn read_config(&mut self) -> Result<(), SerializerError> {
// Open bookmarks file for read
match OpenOptions::new()
.read(true)
.open(self.config_path.as_path())
{
Ok(reader) => {
// Deserialize
let deserializer: ConfigSerializer = ConfigSerializer {};
match deserializer.deserialize(Box::new(reader)) {
Ok(config) => {
self.config = config;
Ok(())
}
Err(err) => Err(err),
}
}
Err(err) => Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
)),
}
}
/// ### make_ssh_host_key
///
/// Hosts are saved as `username@host` into configuration.
/// This method creates the key name, starting from host and username
fn make_ssh_host_key(host: &str, username: &str) -> String {
format!("{}@{}", username, host)
}
/// ### get_ssh_tokens
///
/// Get ssh tokens starting from ssh host key
/// Panics if key has invalid syntax
/// Returns: (host, username)
fn get_ssh_tokens(host_key: &str) -> (String, String) {
let tokens: Vec<&str> = host_key.split('@').collect();
assert_eq!(tokens.len(), 2);
(String::from(tokens[1]), String::from(tokens[0]))
}
/// ### make_io_err
///
/// Make serializer error from `std::io::Error`
fn make_io_err(err: std::io::Error) -> Result<(), SerializerError> {
Err(SerializerError::new_ex(
SerializerErrorKind::IoError,
err.to_string(),
))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::UserConfig;
use crate::utils::random::random_alphanumeric_with_len;
use std::io::Read;
#[test]
fn test_system_config_new() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, ssh_keys_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), ssh_keys_path.as_path())
.ok()
.unwrap();
// Verify parameters
let default_config: UserConfig = UserConfig::default();
assert_eq!(client.config.remote.ssh_keys.len(), 0);
assert_eq!(
client.config.user_interface.default_protocol,
default_config.user_interface.default_protocol
);
assert_eq!(
client.config.user_interface.text_editor,
default_config.user_interface.text_editor
);
assert_eq!(client.config_path, cfg_path);
assert_eq!(client.ssh_key_dir, ssh_keys_path);
}
#[test]
fn test_system_config_new_err() {
assert!(
ConfigClient::new(Path::new("/tmp/oifoif/omar"), Path::new("/tmp/efnnu/omar"),)
.is_err()
);
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, _): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
assert!(ConfigClient::new(cfg_path.as_path(), Path::new("/tmp/efnnu/omar")).is_err());
}
#[test]
fn test_system_config_from_existing() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
// Change some stuff
client.set_text_editor(PathBuf::from("/usr/bin/vim"));
client.set_default_protocol(FileTransferProtocol::Scp);
assert!(client
.add_ssh_key("192.168.1.31", "pi", "piroporopero")
.is_ok());
assert!(client.write_config().is_ok());
// Istantiate a new client
let client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
// Verify client has updated parameters
assert_eq!(client.get_default_protocol(), FileTransferProtocol::Scp);
assert_eq!(client.get_text_editor(), PathBuf::from("/usr/bin/vim"));
let mut expected_key_path: PathBuf = key_path.clone();
expected_key_path.push("pi@192.168.1.31.key");
assert_eq!(
client.get_ssh_key("pi@192.168.1.31").unwrap().unwrap(),
(
String::from("192.168.1.31"),
String::from("pi"),
expected_key_path,
)
);
}
#[test]
fn test_system_config_text_editor() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
client.set_text_editor(PathBuf::from("mcedit"));
assert_eq!(client.get_text_editor(), PathBuf::from("mcedit"));
}
#[test]
fn test_system_config_default_protocol() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
client.set_default_protocol(FileTransferProtocol::Ftp(true));
assert_eq!(
client.get_default_protocol(),
FileTransferProtocol::Ftp(true)
);
}
#[test]
fn test_system_config_show_hidden_files() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
client.set_show_hidden_files(true);
assert_eq!(client.get_show_hidden_files(), true);
}
#[test]
fn test_system_config_check_for_updates() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
assert_eq!(client.get_check_for_updates(), true); // Null ?
client.set_check_for_updates(true);
assert_eq!(client.get_check_for_updates(), true);
client.set_check_for_updates(false);
assert_eq!(client.get_check_for_updates(), false);
}
#[test]
fn test_system_config_group_dirs() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
client.set_group_dirs(Some(GroupDirs::First));
assert_eq!(client.get_group_dirs(), Some(GroupDirs::First),);
client.set_group_dirs(None);
assert_eq!(client.get_group_dirs(), None,);
}
#[test]
fn test_system_config_file_fmt() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
assert_eq!(client.get_file_fmt(), None);
client.set_file_fmt(String::from("{NAME}"));
assert_eq!(client.get_file_fmt().unwrap(), String::from("{NAME}"));
// Delete
client.set_file_fmt(String::from(""));
assert_eq!(client.get_file_fmt(), None);
}
#[test]
fn test_system_config_ssh_keys() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
// Add a new key
let rsa_key: String = get_sample_rsa_key();
assert!(client
.add_ssh_key("192.168.1.31", "pi", rsa_key.as_str())
.is_ok());
// Iterate keys
for key in client.iter_ssh_keys() {
let host: SshHost = client.get_ssh_key(key).ok().unwrap().unwrap();
println!("{:?}", host);
assert_eq!(host.0, String::from("192.168.1.31"));
assert_eq!(host.1, String::from("pi"));
let mut expected_key_path: PathBuf = key_path.clone();
expected_key_path.push("pi@192.168.1.31.key");
assert_eq!(host.2, expected_key_path);
// Read rsa key
let mut key_file: File = File::open(expected_key_path.as_path()).ok().unwrap();
// Read
let mut key: String = String::new();
assert!(key_file.read_to_string(&mut key).is_ok());
// Verify rsa key
assert_eq!(key, rsa_key);
}
// Unexisting key
assert!(client.get_ssh_key("test").ok().unwrap().is_none());
// Delete key
assert!(client.del_ssh_key("192.168.1.31", "pi").is_ok());
}
#[test]
fn test_system_config_make_key() {
assert_eq!(
ConfigClient::make_ssh_host_key("192.168.1.31", "pi"),
String::from("pi@192.168.1.31")
);
assert_eq!(
ConfigClient::get_ssh_tokens("pi@192.168.1.31"),
(String::from("192.168.1.31"), String::from("pi"))
);
}
#[test]
fn test_system_config_make_io_err() {
let err: SerializerError =
ConfigClient::make_io_err(std::io::Error::from(std::io::ErrorKind::PermissionDenied))
.err()
.unwrap();
assert_eq!(err.to_string(), "IO error (permission denied)");
}
/// ### get_paths
///
/// Get paths for configuration and keys directory
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let mut k: PathBuf = PathBuf::from(dir);
let mut c: PathBuf = k.clone();
k.push("ssh-keys/");
c.push("config.toml");
(c, k)
}
/// ### create_tmp_dir
///
/// Create temporary directory
fn create_tmp_dir() -> tempfile::TempDir {
tempfile::TempDir::new().ok().unwrap()
}
fn get_sample_rsa_key() -> String {
format!(
"-----BEGIN OPENSSH PRIVATE KEY-----\n{}\n-----END OPENSSH PRIVATE KEY-----",
random_alphanumeric_with_len(2536)
)
}
}

138
src/system/environment.rs Normal file
View File

@@ -0,0 +1,138 @@
//! ## Environment
//!
//! `environment` is the module which provides Path and values for the system environment
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate dirs;
// Ext
use std::path::{Path, PathBuf};
/// ### get_config_dir
///
/// Get termscp configuration directory path.
/// Returns None, if it's not possible to get it
pub fn init_config_dir() -> Result<Option<PathBuf>, String> {
// Get file
lazy_static! {
static ref CONF_DIR: Option<PathBuf> = dirs::config_dir();
}
if CONF_DIR.is_some() {
// Get path of bookmarks
let mut p: PathBuf = CONF_DIR.as_ref().unwrap().clone();
// Append termscp dir
p.push("termscp/");
// If directory doesn't exist, create it
match p.exists() {
true => Ok(Some(p)),
false => match std::fs::create_dir(p.as_path()) {
Ok(_) => Ok(Some(p)),
Err(err) => Err(err.to_string()),
},
}
} else {
Ok(None)
}
}
/// ### get_bookmarks_paths
///
/// Get paths for bookmarks client
/// Returns: path of bookmarks.toml
pub fn get_bookmarks_paths(config_dir: &Path) -> PathBuf {
// Prepare paths
let mut bookmarks_file: PathBuf = PathBuf::from(config_dir);
bookmarks_file.push("bookmarks.toml");
bookmarks_file
}
/// ### get_config_paths
///
/// Returns paths for config client
/// Returns: path of config.toml and path for ssh keys
pub fn get_config_paths(config_dir: &Path) -> (PathBuf, PathBuf) {
// Prepare paths
let mut bookmarks_file: PathBuf = PathBuf::from(config_dir);
bookmarks_file.push("config.toml");
let mut keys_dir: PathBuf = PathBuf::from(config_dir);
keys_dir.push(".ssh/"); // Path where keys are stored
(bookmarks_file, keys_dir)
}
#[cfg(test)]
mod tests {
use super::*;
use std::fs::{File, OpenOptions};
use std::io::Write;
#[test]
fn test_system_environment_get_config_dir() {
// Create and get conf_dir
let conf_dir: PathBuf = init_config_dir().ok().unwrap().unwrap();
// Remove dir
assert!(std::fs::remove_dir_all(conf_dir.as_path()).is_ok());
}
#[test]
fn test_system_environment_get_config_dir_err() {
let mut conf_dir: PathBuf = dirs::config_dir().unwrap();
conf_dir.push("termscp");
// Create file
let mut f: File = OpenOptions::new()
.create(true)
.write(true)
.open(conf_dir.as_path())
.ok()
.unwrap();
// Write
assert!(writeln!(f, "Hello world!").is_ok());
// Drop file
drop(f);
// Get config dir (will fail)
assert!(init_config_dir().is_err());
// Remove file
assert!(std::fs::remove_file(conf_dir.as_path()).is_ok());
}
#[test]
fn test_system_environment_get_bookmarks_paths() {
assert_eq!(
get_bookmarks_paths(&Path::new("/home/omar/.config/termscp/")),
PathBuf::from("/home/omar/.config/termscp/bookmarks.toml"),
);
}
#[test]
fn test_system_environment_get_config_paths() {
assert_eq!(
get_config_paths(&Path::new("/home/omar/.config/termscp/")),
(
PathBuf::from("/home/omar/.config/termscp/config.toml"),
PathBuf::from("/home/omar/.config/termscp/.ssh/")
)
);
}
}

View File

@@ -0,0 +1,163 @@
//! ## FileStorage
//!
//! `filestorage` provides an implementation of the `KeyStorage` trait using a file
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Local
use super::{KeyStorage, KeyStorageError};
// Ext
use std::fs::{OpenOptions, Permissions};
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
/// ## FileStorage
///
/// File storage is an implementation o the `KeyStorage` which uses a file to store the key
pub struct FileStorage {
dir_path: PathBuf,
}
impl FileStorage {
/// ### new
///
/// Instantiates a new `FileStorage`
pub fn new(dir_path: &Path) -> Self {
FileStorage {
dir_path: PathBuf::from(dir_path),
}
}
/// ### make_file_path
///
/// Make file path for key file from `dir_path` and the application id
fn make_file_path(&self, storage_id: &str) -> PathBuf {
let mut p: PathBuf = self.dir_path.clone();
let file_name = format!(".{}.key", storage_id);
p.push(file_name);
p
}
}
impl KeyStorage for FileStorage {
/// ### get_key
///
/// Retrieve key from the key storage.
/// The key might be acccess through an identifier, which identifies
/// the key in the storage
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError> {
let key_file: PathBuf = self.make_file_path(storage_id);
// Check if file exists
if !key_file.exists() {
return Err(KeyStorageError::NoSuchKey);
}
// Read key from file
match OpenOptions::new().read(true).open(key_file.as_path()) {
Ok(mut file) => {
let mut key: String = String::new();
match file.read_to_string(&mut key) {
Ok(_) => Ok(key),
Err(_) => Err(KeyStorageError::ProviderError),
}
}
Err(_) => Err(KeyStorageError::ProviderError),
}
}
/// ### set_key
///
/// Set the key into the key storage
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError> {
let key_file: PathBuf = self.make_file_path(storage_id);
// Write key
match OpenOptions::new()
.write(true)
.create(true)
.truncate(true)
.open(key_file.as_path())
{
Ok(mut file) => {
// Write key to file
if file.write_all(key.as_bytes()).is_err() {
return Err(KeyStorageError::ProviderError);
}
// Set file to readonly
let mut permissions: Permissions = file.metadata().unwrap().permissions();
permissions.set_readonly(true);
let _ = file.set_permissions(permissions);
Ok(())
}
Err(_) => Err(KeyStorageError::ProviderError),
}
}
/// is_supported
///
/// Returns whether the key storage is supported on the host system
fn is_supported(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_keys_filestorage_make_dir() {
let storage: FileStorage = FileStorage::new(&Path::new("/tmp/"));
assert_eq!(
storage.make_file_path("bookmarks").as_path(),
Path::new("/tmp/.bookmarks.key")
);
}
#[test]
fn test_system_keys_filestorage_ok() {
let key_dir: tempfile::TempDir =
tempfile::TempDir::new().expect("Could not create tempdir");
let storage: FileStorage = FileStorage::new(key_dir.path());
// Supported
assert!(storage.is_supported());
let app_name: &str = "termscp";
let secret: &str = "Th15-15/My-Супер-Секрет";
// Secret should not exist
assert_eq!(
storage.get_key(app_name).err().unwrap(),
KeyStorageError::NoSuchKey
);
// Write secret
assert!(storage.set_key(app_name, secret).is_ok());
// Get secret
assert_eq!(storage.get_key(app_name).ok().unwrap().as_str(), secret);
}
#[test]
fn test_system_keys_filestorage_err() {
let bad_dir: &Path = Path::new("/piro/poro/pero/");
let storage: FileStorage = FileStorage::new(bad_dir);
let app_name: &str = "termscp";
let secret: &str = "Th15-15/My-Супер-Секрет";
assert!(storage.set_key(app_name, secret).is_err());
}
}

View File

@@ -0,0 +1,129 @@
//! ## KeyringStorage
//!
//! `keyringstorage` provides an implementation of the `KeyStorage` trait using the OS keyring
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate keyring;
// Local
use super::{KeyStorage, KeyStorageError};
// Ext
use keyring::{Keyring, KeyringError};
/// ## KeyringStorage
///
/// provides a `KeyStorage` implementation using the keyring crate
pub struct KeyringStorage {
username: String,
}
impl KeyringStorage {
/// ### new
///
/// Instantiates a new KeyringStorage
pub fn new(username: &str) -> Self {
KeyringStorage {
username: username.to_string(),
}
}
}
impl KeyStorage for KeyringStorage {
/// ### get_key
///
/// Retrieve key from the key storage.
/// The key might be acccess through an identifier, which identifies
/// the key in the storage
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError> {
let storage: Keyring = Keyring::new(storage_id, self.username.as_str());
match storage.get_password() {
Ok(s) => Ok(s),
Err(e) => match e {
KeyringError::NoPasswordFound => Err(KeyStorageError::NoSuchKey),
#[cfg(target_os = "windows")]
KeyringError::WindowsVaultError => Err(KeyStorageError::NoSuchKey),
#[cfg(target_os = "macos")]
KeyringError::MacOsKeychainError(_) => Err(KeyStorageError::NoSuchKey),
_ => panic!("{}", e),
},
}
}
/// ### set_key
///
/// Set the key into the key storage
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError> {
let storage: Keyring = Keyring::new(storage_id, self.username.as_str());
match storage.set_password(key) {
Ok(_) => Ok(()),
Err(_) => Err(KeyStorageError::ProviderError),
}
}
/// is_supported
///
/// Returns whether the key storage is supported on the host system
fn is_supported(&self) -> bool {
let dummy: String = String::from("dummy-service");
let storage: Keyring = Keyring::new(dummy.as_str(), self.username.as_str());
// Check what kind of error is returned
match storage.get_password() {
Ok(_) => true,
Err(err) => !matches!(err, KeyringError::NoBackendFound),
}
}
}
#[cfg(test)]
mod tests {
extern crate whoami;
use super::*;
use whoami::username;
#[test]
fn test_system_keys_keyringstorage() {
let username: String = username();
let storage: KeyringStorage = KeyringStorage::new(username.as_str());
assert!(storage.is_supported());
let app_name: &str = "termscp-test2";
let secret: &str = "Th15-15/My-Супер-Секрет";
let kring: Keyring = Keyring::new(app_name, username.as_str());
let _ = kring.delete_password();
drop(kring);
// Secret should not exist
assert_eq!(
storage.get_key(app_name).err().unwrap(),
KeyStorageError::NoSuchKey
);
// Write secret
assert!(storage.set_key(app_name, secret).is_ok());
// Get secret
assert_eq!(storage.get_key(app_name).ok().unwrap().as_str(), secret);
// Delete the key manually...
let kring: Keyring = Keyring::new(app_name, username.as_str());
assert!(kring.delete_password().is_ok());
}
}

90
src/system/keys/mod.rs Normal file
View File

@@ -0,0 +1,90 @@
//! ## KeyStorage
//!
//! `keystorage` provides the trait to manipulate to a KeyStorage
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Storages
pub mod filestorage;
#[cfg(any(target_os = "windows", target_os = "macos"))]
pub mod keyringstorage;
/// ## KeyStorageError
///
/// defines the error type for the `KeyStorage`
#[derive(PartialEq, std::fmt::Debug)]
pub enum KeyStorageError {
//BadKey,
ProviderError,
NoSuchKey,
}
impl std::fmt::Display for KeyStorageError {
fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result {
let err: String = String::from(match &self {
//KeyStorageError::BadKey => "Bad key syntax",
KeyStorageError::ProviderError => "Provider service error",
KeyStorageError::NoSuchKey => "No such key",
});
write!(f, "{}", err)
}
}
/// ## KeyStorage
///
/// this traits provides the methods to communicate and interact with the key storage.
pub trait KeyStorage {
/// ### get_key
///
/// Retrieve key from the key storage.
/// The key might be acccess through an identifier, which identifies
/// the key in the storage
fn get_key(&self, storage_id: &str) -> Result<String, KeyStorageError>;
/// ### set_key
///
/// Set the key into the key storage
fn set_key(&self, storage_id: &str, key: &str) -> Result<(), KeyStorageError>;
/// is_supported
///
/// Returns whether the key storage is supported on the host system
fn is_supported(&self) -> bool;
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_system_keys_mod_errors() {
assert_eq!(
format!("{}", KeyStorageError::ProviderError),
String::from("Provider service error")
);
assert_eq!(
format!("{}", KeyStorageError::NoSuchKey),
String::from("No such key")
);
}
}

31
src/system/mod.rs Normal file
View File

@@ -0,0 +1,31 @@
//! ## System
//!
//! `system` is the module which contains functions and data types related to current system
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// modules
pub mod bookmarks_client;
pub mod config_client;
pub mod environment;
pub(crate) mod keys;
pub mod sshkey_storage;

View File

@@ -0,0 +1,140 @@
//! ## SshKeyStorage
//!
//! `SshKeyStorage` is the module which behaves a storage for ssh keys
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::config_client::ConfigClient;
// Ext
use std::collections::HashMap;
use std::path::PathBuf;
pub struct SshKeyStorage {
hosts: HashMap<String, PathBuf>, // Association between {user}@{host} and RSA key path
}
impl SshKeyStorage {
/// ### storage_from_config
///
/// Create a `SshKeyStorage` starting from a `ConfigClient`
pub fn storage_from_config(cfg_client: &ConfigClient) -> Self {
let mut hosts: HashMap<String, PathBuf> =
HashMap::with_capacity(cfg_client.iter_ssh_keys().count());
// Iterate over keys
for key in cfg_client.iter_ssh_keys() {
match cfg_client.get_ssh_key(key) {
Ok(host) => match host {
Some((addr, username, rsa_key_path)) => {
let key_name: String = Self::make_mapkey(&addr, &username);
hosts.insert(key_name, rsa_key_path);
}
None => continue,
},
Err(_) => continue,
}
}
// Return storage
SshKeyStorage { hosts }
}
/// ### empty
///
/// Create an empty ssh key storage; used in case `ConfigClient` is not available
pub fn empty() -> Self {
SshKeyStorage {
hosts: HashMap::new(),
}
}
/// ### resolve
///
/// Return RSA key path from host and username
pub fn resolve(&self, host: &str, username: &str) -> Option<&PathBuf> {
let key: String = Self::make_mapkey(host, username);
self.hosts.get(&key)
}
/// ### make_mapkey
///
/// Make mapkey from host and username
fn make_mapkey(host: &str, username: &str) -> String {
format!("{}@{}", username, host)
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::system::config_client::ConfigClient;
use std::path::Path;
#[test]
fn test_system_sshkey_storage_new() {
let tmp_dir: tempfile::TempDir = create_tmp_dir();
let (cfg_path, key_path): (PathBuf, PathBuf) = get_paths(tmp_dir.path());
let mut client: ConfigClient = ConfigClient::new(cfg_path.as_path(), key_path.as_path())
.ok()
.unwrap();
// Add ssh key
assert!(client
.add_ssh_key("192.168.1.31", "pi", "piroporopero")
.is_ok());
// Create ssh key storage
let storage: SshKeyStorage = SshKeyStorage::storage_from_config(&client);
// Verify key exists
let mut exp_key_path: PathBuf = key_path.clone();
exp_key_path.push("pi@192.168.1.31.key");
assert_eq!(
*storage.resolve("192.168.1.31", "pi").unwrap(),
exp_key_path
);
// Verify unexisting key
assert!(storage.resolve("deskichup", "veeso").is_none());
}
#[test]
fn test_system_sshkey_storage_empty() {
let storage: SshKeyStorage = SshKeyStorage::empty();
assert_eq!(storage.hosts.len(), 0);
}
/// ### get_paths
///
/// Get paths for configuration and keys directory
fn get_paths(dir: &Path) -> (PathBuf, PathBuf) {
let mut k: PathBuf = PathBuf::from(dir);
let mut c: PathBuf = k.clone();
k.push("ssh-keys/");
c.push("config.toml");
(c, k)
}
/// ### create_tmp_dir
///
/// Create temporary directory
fn create_tmp_dir() -> tempfile::TempDir {
tempfile::TempDir::new().ok().unwrap()
}
}

View File

@@ -1,542 +0,0 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Dependencies
extern crate crossterm;
extern crate tui;
extern crate unicode_width;
// locals
use super::{Activity, Context};
use crate::filetransfer::FileTransferProtocol;
// Includes
use crossterm::event::Event as InputEvent;
use crossterm::event::KeyCode;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tui::{
layout::{Constraint, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Block, Borders, Clear, Paragraph, Tabs},
};
use unicode_width::UnicodeWidthStr;
/// ### InputField
///
/// InputField describes the current input field to edit
#[derive(std::cmp::PartialEq)]
enum InputField {
Address,
Port,
Protocol,
Username,
Password,
}
/// ### InputMode
///
/// InputMode describes the current input mode
/// Each input mode handle the input events in a different way
#[derive(std::cmp::PartialEq)]
enum InputMode {
Text,
Popup,
}
/// ### AuthActivity
///
/// AuthActivity is the data holder for the authentication activity
pub struct AuthActivity {
pub address: String,
pub port: String,
pub protocol: FileTransferProtocol,
pub username: String,
pub password: String,
pub submit: bool, // becomes true after user has submitted fields
pub quit: bool, // Becomes true if user has pressed esc
context: Option<Context>,
selected_field: InputField,
input_mode: InputMode,
popup_message: Option<String>,
password_placeholder: String,
redraw: bool, // Should ui actually be redrawned?
}
impl Default for AuthActivity {
fn default() -> Self {
Self::new()
}
}
impl AuthActivity {
/// ### new
///
/// Instantiates a new AuthActivity
pub fn new() -> AuthActivity {
AuthActivity {
address: String::new(),
port: String::from("22"),
protocol: FileTransferProtocol::Sftp,
username: String::new(),
password: String::new(),
submit: false,
quit: false,
context: None,
selected_field: InputField::Address,
input_mode: InputMode::Text,
popup_message: None,
password_placeholder: String::new(),
redraw: true, // True at startup
}
}
/// ### set_input_mode
///
/// Update input mode based on current parameters
fn select_input_mode(&mut self) -> InputMode {
if self.popup_message.is_some() {
return InputMode::Popup;
}
// Default to text
InputMode::Text
}
/// ### handle_input_event
///
/// Handle input event, based on current input mode
fn handle_input_event(&mut self, ev: &InputEvent) {
match self.input_mode {
InputMode::Text => self.handle_input_event_mode_text(ev),
InputMode::Popup => self.handle_input_event_mode_popup(ev),
}
}
/// ### handle_input_event_mode_text
///
/// Handler for input event when in textmode
fn handle_input_event_mode_text(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
self.quit = true;
}
KeyCode::Enter => {
// Handle submit
// Check form
// Check address
if self.address.is_empty() {
self.popup_message = Some(String::from("Invalid address"));
return;
}
// Check port
// Convert port to number
match self.port.parse::<usize>() {
Ok(val) => {
if val > 65535 {
self.popup_message =
Some(String::from("Specified port must be in range 0-65535"));
return;
}
}
Err(_) => {
self.popup_message =
Some(String::from("Specified port is not a number"));
return;
}
}
// Check username
//if self.username.len() == 0 {
// self.popup_message = Some(String::from("Invalid username"));
// return;
//}
// Everything OK, set enter
self.submit = true;
self.popup_message =
Some(format!("Connecting to {}:{}...", self.address, self.port));
}
KeyCode::Backspace => {
// Pop last char
match self.selected_field {
InputField::Address => {
let _ = self.address.pop();
}
InputField::Password => {
let _ = self.password.pop();
}
InputField::Username => {
let _ = self.username.pop();
}
InputField::Port => {
let _ = self.port.pop();
}
_ => { /* Nothing to do */ }
};
}
KeyCode::Up => {
// Move item up
self.selected_field = match self.selected_field {
InputField::Address => InputField::Password, // End of list (wrap)
InputField::Port => InputField::Address,
InputField::Protocol => InputField::Port,
InputField::Username => InputField::Protocol,
InputField::Password => InputField::Username,
}
}
KeyCode::Down | KeyCode::Tab => {
// Move item down
self.selected_field = match self.selected_field {
InputField::Address => InputField::Port,
InputField::Port => InputField::Protocol,
InputField::Protocol => InputField::Username,
InputField::Username => InputField::Password,
InputField::Password => InputField::Address, // End of list (wrap)
}
}
KeyCode::Char(ch) => {
match self.selected_field {
InputField::Address => self.address.push(ch),
InputField::Password => self.password.push(ch),
InputField::Username => self.username.push(ch),
InputField::Port => {
// Value must be numeric
if ch.is_numeric() {
self.port.push(ch);
}
}
_ => { /* Nothing to do */ }
}
}
KeyCode::Left => {
// If current field is Protocol handle event... (move element left)
if self.selected_field == InputField::Protocol {
self.protocol = match self.protocol {
FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap)
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
FileTransferProtocol::Ftp(ftps) => match ftps {
false => FileTransferProtocol::Scp,
true => FileTransferProtocol::Ftp(false),
},
};
}
}
KeyCode::Right => {
// If current field is Protocol handle event... ( move element right )
if self.selected_field == InputField::Protocol {
self.protocol = match self.protocol {
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false),
FileTransferProtocol::Ftp(ftps) => match ftps {
false => FileTransferProtocol::Ftp(true),
true => FileTransferProtocol::Sftp, // End of list (wrap)
},
};
}
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_text
///
/// Handler for input event when in popup mode
fn handle_input_event_mode_popup(&mut self, ev: &InputEvent) {
// Only enter should be allowed here
if let InputEvent::Key(key) = ev {
if let KeyCode::Enter = key.code {
self.popup_message = None; // Hide popup
}
}
}
/// ### draw_remote_address
///
/// Draw remote address block
fn draw_remote_address(&self) -> Paragraph {
Paragraph::new(self.address.as_ref())
.style(match self.selected_field {
InputField::Address => Style::default().fg(Color::Yellow),
_ => Style::default(),
})
.block(
Block::default()
.borders(Borders::ALL)
.title("Remote address"),
)
}
/// ### draw_remote_port
///
/// Draw remote port block
fn draw_remote_port(&self) -> Paragraph {
Paragraph::new(self.port.as_ref())
.style(match self.selected_field {
InputField::Port => Style::default().fg(Color::Cyan),
_ => Style::default(),
})
.block(Block::default().borders(Borders::ALL).title("Remote port"))
}
/// ### draw_protocol_select
///
/// Draw protocol select
fn draw_protocol_select(&self) -> Tabs {
let protocols: Vec<Spans> = vec![
Spans::from("SFTP"),
Spans::from("SCP"),
Spans::from("FTP"),
Spans::from("FTPS"),
];
let index: usize = match self.protocol {
FileTransferProtocol::Sftp => 0,
FileTransferProtocol::Scp => 1,
FileTransferProtocol::Ftp(ftps) => match ftps {
false => 2,
true => 3,
},
};
Tabs::new(protocols)
.block(Block::default().borders(Borders::ALL).title("Protocol"))
.select(index)
.style(match self.selected_field {
InputField::Protocol => Style::default().fg(Color::Green),
_ => Style::default(),
})
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Green)
.fg(Color::Black),
)
}
/// ### draw_protocol_username
///
/// Draw username block
fn draw_protocol_username(&self) -> Paragraph {
Paragraph::new(self.username.as_ref())
.style(match self.selected_field {
InputField::Username => Style::default().fg(Color::Magenta),
_ => Style::default(),
})
.block(Block::default().borders(Borders::ALL).title("Username"))
}
/// ### draw_protocol_password
///
/// Draw password block
fn draw_protocol_password(&mut self) -> Paragraph {
// Create password secret
self.password_placeholder = (0..self.password.width()).map(|_| "*").collect::<String>();
Paragraph::new(self.password_placeholder.as_ref())
.style(match self.selected_field {
InputField::Password => Style::default().fg(Color::LightBlue),
_ => Style::default(),
})
.block(Block::default().borders(Borders::ALL).title("Password"))
}
/// ### draw_header
///
/// Draw header
fn draw_header(&self) -> Paragraph {
Paragraph::new(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n")
.style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD))
}
/// ### draw_footer
///
/// Draw authentication page footer
fn draw_footer(&self) -> Paragraph {
// Write header
let (footer, h_style) = (
vec![
Span::raw("Press "),
Span::styled("<ESC>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to exit, "),
Span::styled("<UP,DOWN>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to change input field, "),
Span::styled("<ENTER>", Style::default().add_modifier(Modifier::BOLD)),
Span::raw(" to submit form"),
],
Style::default().add_modifier(Modifier::BOLD),
);
let mut footer_text = Text::from(Spans::from(footer));
footer_text.patch_style(h_style);
Paragraph::new(footer_text)
}
/// ### draw_popup
///
/// Draw popup block
fn draw_popup(&self, r: Rect) -> (Paragraph, Rect) {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((80) / 2),
Constraint::Percentage(20),
Constraint::Percentage((80) / 2),
]
.as_ref(),
)
.split(r);
let area: Rect = Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((80) / 2),
Constraint::Percentage(20),
Constraint::Percentage((80) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1];
let popup: Paragraph = Paragraph::new(self.popup_message.as_ref().unwrap().as_ref())
.style(Style::default().fg(Color::Red))
.block(Block::default().borders(Borders::ALL).title("Alert"));
(popup, area)
}
}
impl Activity for AuthActivity {
/// ### on_create
///
/// `on_create` is the function which must be called to initialize the activity.
/// `on_create` must initialize all the data structures used by the activity
/// Context is taken from activity manager and will be released only when activity is destroyed
fn on_create(&mut self, context: Context) {
// Set context
self.context = Some(context);
// Clear terminal
let _ = self.context.as_mut().unwrap().terminal.clear();
// Put raw mode on enabled
let _ = enable_raw_mode();
}
/// ### on_draw
///
/// `on_draw` is the function which draws the graphical interface.
/// This function must be called at each tick to refresh the interface
fn on_draw(&mut self) {
// Context must be something
if self.context.is_none() {
return;
}
// Start catching Input Events
if let Ok(input_events) = self.context.as_ref().unwrap().input_hnd.fetch_events() {
if !input_events.is_empty() {
self.redraw = true; // Set redraw to true if there is at least one event
}
// Iterate over input events
for event in input_events.iter() {
self.handle_input_event(event);
}
}
// Redraw if necessary
if self.redraw {
// Determine input mode
self.input_mode = self.select_input_mode();
// draw interface
let mut ctx: Context = self.context.take().unwrap();
let _ = ctx.terminal.draw(|f| {
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(2)
.constraints(
[
Constraint::Length(5),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
]
.as_ref(),
)
.split(f.size());
// Draw header
f.render_widget(self.draw_header(), chunks[0]);
// Draw input fields
f.render_widget(self.draw_remote_address(), chunks[1]);
f.render_widget(self.draw_remote_port(), chunks[2]);
f.render_widget(self.draw_protocol_select(), chunks[3]);
f.render_widget(self.draw_protocol_username(), chunks[4]);
f.render_widget(self.draw_protocol_password(), chunks[5]);
// Draw footer
f.render_widget(self.draw_footer(), chunks[6]);
if self.popup_message.is_some() {
let (popup, popup_area): (Paragraph, Rect) = self.draw_popup(f.size());
f.render_widget(Clear, popup_area); //this clears out the background
f.render_widget(popup, popup_area);
}
// Set cursor
match self.selected_field {
InputField::Address => f.set_cursor(
chunks[1].x + self.address.width() as u16 + 1,
chunks[1].y + 1,
),
InputField::Port => {
f.set_cursor(chunks[2].x + self.port.width() as u16 + 1, chunks[2].y + 1)
}
InputField::Username => f.set_cursor(
chunks[4].x + self.username.width() as u16 + 1,
chunks[4].y + 1,
),
InputField::Password => f.set_cursor(
chunks[5].x + self.password_placeholder.width() as u16 + 1,
chunks[5].y + 1,
),
_ => {}
}
});
// Reset ctx
self.context = Some(ctx);
// Set redraw to false
self.redraw = false;
}
}
/// ### on_destroy
///
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
/// This function must be called once before terminating the activity.
/// This function finally releases the context
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
self.context.as_ref()?;
// Clear terminal and return
match self.context.take() {
Some(mut ctx) => {
let _ = ctx.terminal.clear();
Some(ctx)
}
None => None,
}
}
}

View File

@@ -0,0 +1,281 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Dependencies
extern crate dirs;
// Locals
use super::{AuthActivity, Color, DialogYesNoOption, Popup};
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::environment;
// Ext
use std::path::PathBuf;
impl AuthActivity {
/// ### del_bookmark
///
/// Delete bookmark
pub(super) fn del_bookmark(&mut self, idx: usize) {
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
// Iterate over kyes
let name: Option<&String> = self.bookmarks_list.get(idx);
if let Some(name) = name {
bookmarks_cli.del_bookmark(&name);
// Write bookmarks
self.write_bookmarks();
}
// Delete element from vec
self.recents_list.remove(idx);
}
}
/// ### load_bookmark
///
/// Load selected bookmark (at index) to input fields
pub(super) fn load_bookmark(&mut self, idx: usize) {
if let Some(bookmarks_cli) = self.bookmarks_client.as_ref() {
// Iterate over bookmarks
if let Some(key) = self.bookmarks_list.get(idx) {
if let Some(bookmark) = bookmarks_cli.get_bookmark(&key) {
// Load parameters
self.address = bookmark.0;
self.port = bookmark.1.to_string();
self.protocol = bookmark.2;
self.username = bookmark.3;
if let Some(password) = bookmark.4 {
self.password = password;
}
}
}
}
}
/// ### save_bookmark
///
/// Save current input fields as a bookmark
pub(super) fn save_bookmark(&mut self, name: String) {
// Check port
let port: u16 = match self.port.parse::<usize>() {
Ok(val) => {
if val > 65535 {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port must be in range 0-65535"),
));
return;
}
val as u16
}
Err(_) => {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port is not a number"),
));
return;
}
};
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
// Check if password must be saved
let password: Option<String> = match self.choice_opt {
DialogYesNoOption::Yes => Some(self.password.clone()),
DialogYesNoOption::No => None,
};
bookmarks_cli.add_bookmark(
name.clone(),
self.address.clone(),
port,
self.protocol,
self.username.clone(),
password,
);
// Save bookmarks
self.write_bookmarks();
// Push bookmark to list and sort
self.bookmarks_list.push(name);
self.sort_bookmarks();
}
}
/// ### del_recent
///
/// Delete recent
pub(super) fn del_recent(&mut self, idx: usize) {
if let Some(client) = self.bookmarks_client.as_mut() {
let name: Option<&String> = self.recents_list.get(idx);
if let Some(name) = name {
client.del_recent(&name);
// Write bookmarks
self.write_bookmarks();
}
// Delete element from vec
self.recents_list.remove(idx);
}
}
/// ### load_recent
///
/// Load selected recent (at index) to input fields
pub(super) fn load_recent(&mut self, idx: usize) {
if let Some(client) = self.bookmarks_client.as_ref() {
// Iterate over bookmarks
if let Some(key) = self.recents_list.get(idx) {
if let Some(bookmark) = client.get_recent(key) {
// Load parameters
self.address = bookmark.0;
self.port = bookmark.1.to_string();
self.protocol = bookmark.2;
self.username = bookmark.3;
}
}
}
}
/// ### save_recent
///
/// Save current input fields as a "recent"
pub(super) fn save_recent(&mut self) {
// Check port
let port: u16 = match self.port.parse::<usize>() {
Ok(val) => {
if val > 65535 {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port must be in range 0-65535"),
));
return;
}
val as u16
}
Err(_) => {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port is not a number"),
));
return;
}
};
if let Some(bookmarks_cli) = self.bookmarks_client.as_mut() {
bookmarks_cli.add_recent(
self.address.clone(),
port,
self.protocol,
self.username.clone(),
);
// Save bookmarks
self.write_bookmarks();
}
}
/// ### write_bookmarks
///
/// Write bookmarks to file
fn write_bookmarks(&mut self) {
if let Some(bookmarks_cli) = self.bookmarks_client.as_ref() {
if let Err(err) = bookmarks_cli.write_bookmarks() {
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not write bookmarks: {}", err),
));
}
}
}
/// ### init_bookmarks_client
///
/// Initialize bookmarks client
pub(super) fn init_bookmarks_client(&mut self) {
// Get config dir
match environment::init_config_dir() {
Ok(path) => {
// If some configure client, otherwise do nothing; don't bother users telling them that bookmarks are not supported on their system.
if let Some(config_dir_path) = path {
let bookmarks_file: PathBuf =
environment::get_bookmarks_paths(config_dir_path.as_path());
// Initialize client
match BookmarksClient::new(
bookmarks_file.as_path(),
config_dir_path.as_path(),
16,
) {
Ok(cli) => {
// Load bookmarks into list
let mut bookmarks_list: Vec<String> =
Vec::with_capacity(cli.iter_bookmarks().count());
for bookmark in cli.iter_bookmarks() {
bookmarks_list.push(bookmark.clone());
}
// Load recents into list
let mut recents_list: Vec<String> =
Vec::with_capacity(cli.iter_recents().count());
for recent in cli.iter_recents() {
recents_list.push(recent.clone());
}
self.bookmarks_client = Some(cli);
self.bookmarks_list = bookmarks_list;
self.recents_list = recents_list;
// Sort bookmark list
self.sort_bookmarks();
self.sort_recents();
}
Err(err) => {
self.popup = Some(Popup::Alert(
Color::Red,
format!(
"Could not initialize bookmarks (at \"{}\", \"{}\"): {}",
bookmarks_file.display(),
config_dir_path.display(),
err
),
))
}
}
}
}
Err(err) => {
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not initialize configuration directory: {}", err),
))
}
}
}
/// ### sort_bookmarks
///
/// Sort bookmarks in list
fn sort_bookmarks(&mut self) {
// Conver to lowercase when sorting
self.bookmarks_list
.sort_by(|a, b| a.to_lowercase().as_str().cmp(b.to_lowercase().as_str()));
}
/// ### sort_recents
///
/// Sort recents in list
fn sort_recents(&mut self) {
// Reverse order
self.recents_list.sort_by(|a, b| b.cmp(a));
}
}

View File

@@ -0,0 +1,60 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::{AuthActivity, InputForm};
impl AuthActivity {
/// ### callback_nothing_to_do
///
/// Self titled
pub(super) fn callback_nothing_to_do(&mut self) {}
/// ### callback_quit
///
/// Self titled
pub(super) fn callback_quit(&mut self) {
self.quit = true;
}
/// ### callback_del_bookmark
///
/// Callback which deletes recent or bookmark based on current form
pub(super) fn callback_del_bookmark(&mut self) {
match self.input_form {
InputForm::Bookmarks => self.del_bookmark(self.bookmarks_idx),
InputForm::Recents => self.del_recent(self.recents_idx),
_ => { /* Nothing to do */ }
}
}
/// ### callback_save_bookmark
///
/// Callback used to save bookmark with name
pub(super) fn callback_save_bookmark(&mut self, input: String) {
if !input.is_empty() {
self.save_bookmark(input);
}
}
}

View File

@@ -0,0 +1,493 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::{
AuthActivity, DialogCallback, DialogYesNoOption, FileTransferProtocol, InputEvent, InputField,
InputForm, Popup,
};
use crossterm::event::{KeyCode, KeyModifiers};
use tui::style::Color;
impl AuthActivity {
/// ### handle_input_event
///
/// Handle input event, based on current input mode
pub(super) fn handle_input_event(&mut self, ev: &InputEvent) {
let popup: Option<Popup> = match &self.popup {
Some(ptype) => Some(ptype.clone()),
_ => None,
};
match &self.popup {
None => self.handle_input_event_mode_form(ev),
Some(_) => {
if let Some(ptype) = popup {
self.handle_input_event_mode_popup(ev, ptype)
}
}
}
}
/// ### handle_input_event_mode_form
///
/// Handler for input event when in form mode
fn handle_input_event_mode_form(&mut self, ev: &InputEvent) {
match self.input_form {
InputForm::AuthCredentials => self.handle_input_event_mode_form_auth(ev),
InputForm::Bookmarks => self.handle_input_event_mode_form_bookmarks(ev),
InputForm::Recents => self.handle_input_event_mode_form_recents(ev),
}
}
/// ### handle_input_event_mode_form_auth
///
/// Handle input event when input mode is Form and Tab is Auth
fn handle_input_event_mode_form_auth(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Show quit dialog
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to quit termscp?"),
AuthActivity::callback_quit,
AuthActivity::callback_nothing_to_do,
));
}
KeyCode::Tab => self.input_form = InputForm::Bookmarks, // Move to bookmarks
KeyCode::Enter => {
// Handle submit
// Check form
// Check address
if self.address.is_empty() {
self.popup =
Some(Popup::Alert(Color::Red, String::from("Invalid address")));
return;
}
// Check port
// Convert port to number
match self.port.parse::<usize>() {
Ok(val) => {
if val > 65535 {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port must be in range 0-65535"),
));
return;
}
}
Err(_) => {
self.popup = Some(Popup::Alert(
Color::Red,
String::from("Specified port is not a number"),
));
return;
}
}
// Save recent
self.save_recent();
// Everything OK, set enter
self.submit = true;
}
KeyCode::Backspace => {
// Pop last char
match self.selected_field {
InputField::Address => {
let _ = self.address.pop();
}
InputField::Password => {
let _ = self.password.pop();
}
InputField::Username => {
let _ = self.username.pop();
}
InputField::Port => {
let _ = self.port.pop();
}
_ => { /* Nothing to do */ }
};
}
KeyCode::Up => {
// Move item up
self.selected_field = match self.selected_field {
InputField::Address => InputField::Password, // End of list (wrap)
InputField::Port => InputField::Address,
InputField::Protocol => InputField::Port,
InputField::Username => InputField::Protocol,
InputField::Password => InputField::Username,
}
}
KeyCode::Down => {
// Move item down
self.selected_field = match self.selected_field {
InputField::Address => InputField::Port,
InputField::Port => InputField::Protocol,
InputField::Protocol => InputField::Username,
InputField::Username => InputField::Password,
InputField::Password => InputField::Address, // End of list (wrap)
}
}
KeyCode::Char(ch) => {
// Check if Ctrl is enabled
if key.modifiers.intersects(KeyModifiers::CONTROL) {
// If 'S', save bookmark as...
match ch {
'H' | 'h' => {
// Show help
self.popup = Some(Popup::Help);
}
'C' | 'c' => {
// Show setup
self.setup = true;
}
'S' | 's' => {
// Default choice option to no
self.choice_opt = DialogYesNoOption::No;
// Save bookmark as...
self.popup = Some(Popup::SaveBookmark);
}
_ => { /* Nothing to do */ }
}
} else {
match self.selected_field {
InputField::Address => self.address.push(ch),
InputField::Password => self.password.push(ch),
InputField::Username => self.username.push(ch),
InputField::Port => {
// Value must be numeric
if ch.is_numeric() {
self.port.push(ch);
}
}
_ => { /* Nothing to do */ }
}
}
}
KeyCode::Left => {
// If current field is Protocol handle event... (move element left)
if self.selected_field == InputField::Protocol {
self.protocol = match self.protocol {
FileTransferProtocol::Sftp => FileTransferProtocol::Ftp(true), // End of list (wrap)
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
FileTransferProtocol::Ftp(ftps) => match ftps {
false => FileTransferProtocol::Scp,
true => FileTransferProtocol::Ftp(false),
},
};
}
}
KeyCode::Right => {
// If current field is Protocol handle event... ( move element right )
if self.selected_field == InputField::Protocol {
self.protocol = match self.protocol {
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
FileTransferProtocol::Scp => FileTransferProtocol::Ftp(false),
FileTransferProtocol::Ftp(ftps) => match ftps {
false => FileTransferProtocol::Ftp(true),
true => FileTransferProtocol::Sftp, // End of list (wrap)
},
};
}
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_form_bookmarks
///
/// Handle input event when input mode is Form and Tab is Bookmarks
fn handle_input_event_mode_form_bookmarks(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Show quit dialog
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to quit termscp?"),
AuthActivity::callback_quit,
AuthActivity::callback_nothing_to_do,
));
}
KeyCode::Tab => self.input_form = InputForm::AuthCredentials, // Move to Auth credentials
KeyCode::Right => self.input_form = InputForm::Recents, // Move to recents
KeyCode::Up => {
// Move bookmarks index up
if self.bookmarks_idx > 0 {
self.bookmarks_idx -= 1;
} else if let Some(bookmarks_cli) = &self.bookmarks_client {
// Put to last index (wrap)
self.bookmarks_idx = bookmarks_cli.iter_bookmarks().count() - 1;
}
}
KeyCode::Down => {
if let Some(bookmarks_cli) = &self.bookmarks_client {
let size: usize = bookmarks_cli.iter_bookmarks().count();
// Check if can move down
if self.bookmarks_idx + 1 >= size {
// Move bookmarks index down
self.bookmarks_idx = 0;
} else {
// Set index to first element (wrap)
self.bookmarks_idx += 1;
}
}
}
KeyCode::Delete => {
// Ask if user wants to delete bookmark
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to delete the selected bookmark?"),
AuthActivity::callback_del_bookmark,
AuthActivity::callback_nothing_to_do,
));
}
KeyCode::Enter => {
// Load bookmark
self.load_bookmark(self.bookmarks_idx);
// Set input form to Auth
self.input_form = InputForm::AuthCredentials;
// Set input field to password (very comfy)
self.selected_field = InputField::Password;
}
KeyCode::Char(ch) => match ch {
'C' | 'c' => {
// Show setup
self.setup = true;
}
'E' | 'e' => {
// Ask if user wants to delete bookmark; NOTE: same as <DEL>
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to delete the selected bookmark?"),
AuthActivity::callback_del_bookmark,
AuthActivity::callback_nothing_to_do,
));
}
'H' | 'h' => {
// Show help
self.popup = Some(Popup::Help);
}
'S' | 's' => {
// Default choice option to no
self.choice_opt = DialogYesNoOption::No;
// Save bookmark as...
self.popup = Some(Popup::SaveBookmark);
}
_ => { /* Nothing to do */ }
},
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_form_recents
///
/// Handle input event when input mode is Form and Tab is Recents
fn handle_input_event_mode_form_recents(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Show quit dialog
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to quit termscp?"),
AuthActivity::callback_quit,
AuthActivity::callback_nothing_to_do,
));
}
KeyCode::Tab => self.input_form = InputForm::AuthCredentials, // Move to Auth credentials
KeyCode::Left => self.input_form = InputForm::Bookmarks, // Move to bookmarks
KeyCode::Up => {
// Move bookmarks index up
if self.recents_idx > 0 {
self.recents_idx -= 1;
} else if let Some(bookmarks_cli) = &self.bookmarks_client {
// Put to last index (wrap)
self.recents_idx = bookmarks_cli.iter_recents().count() - 1;
}
}
KeyCode::Down => {
if let Some(bookmarks_cli) = &self.bookmarks_client {
let size: usize = bookmarks_cli.iter_recents().count();
// Check if can move down
if self.recents_idx + 1 >= size {
// Move bookmarks index down
self.recents_idx = 0;
} else {
// Set index to first element (wrap)
self.recents_idx += 1;
}
}
}
KeyCode::Delete => {
// Ask if user wants to delete bookmark
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to delete the selected host?"),
AuthActivity::callback_del_bookmark,
AuthActivity::callback_nothing_to_do,
));
}
KeyCode::Enter => {
// Load bookmark
self.load_recent(self.recents_idx);
// Set input form to Auth
self.input_form = InputForm::AuthCredentials;
// Set input field to password (very comfy)
self.selected_field = InputField::Password;
}
KeyCode::Char(ch) => match ch {
'C' | 'c' => {
// Show setup
self.setup = true;
}
'E' | 'e' => {
// Ask if user wants to delete bookmark; NOTE: same as <DEL>
self.popup = Some(Popup::YesNo(
String::from("Are you sure you want to delete the selected host?"),
AuthActivity::callback_del_bookmark,
AuthActivity::callback_nothing_to_do,
));
}
'H' | 'h' => {
// Show help
self.popup = Some(Popup::Help);
}
'S' | 's' => {
// Default choice option to no
self.choice_opt = DialogYesNoOption::No;
// Save bookmark as...
self.popup = Some(Popup::SaveBookmark);
}
_ => { /* Nothing to do */ }
},
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_text
///
/// Handler for input event when in popup mode
fn handle_input_event_mode_popup(&mut self, ev: &InputEvent, ptype: Popup) {
match ptype {
Popup::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev),
Popup::Help => self.handle_input_event_mode_popup_help(ev),
Popup::SaveBookmark => self.handle_input_event_mode_popup_save_bookmark(ev),
Popup::YesNo(_, yes_cb, no_cb) => {
self.handle_input_event_mode_popup_yesno(ev, yes_cb, no_cb)
}
}
}
/// ### handle_input_event_mode_popup_alert
///
/// Handle input event when the input mode is popup, and popup type is alert
fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) {
// Only enter should be allowed here
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
self.popup = None; // Hide popup
}
}
}
/// ### handle_input_event_mode_popup_help
///
/// Input event handler for popup help
fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Set input mode back to form
self.popup = None;
}
}
}
/// ### handle_input_event_mode_popup_save_bookmark
///
/// Input event handler for SaveBookmark popup
fn handle_input_event_mode_popup_save_bookmark(&mut self, ev: &InputEvent) {
// If enter, close popup, otherwise push chars to input
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Abort input
// Clear current input text
self.input_txt.clear();
// Set mode back to form
self.popup = None;
// Reset choice option to yes
self.choice_opt = DialogYesNoOption::Yes;
}
KeyCode::Enter => {
// Submit
let input_text: String = self.input_txt.clone();
// Clear current input text
self.input_txt.clear();
// Set mode back to form BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.popup = None;
// Call cb
self.callback_save_bookmark(input_text);
// Reset choice option to yes
self.choice_opt = DialogYesNoOption::Yes;
}
KeyCode::Left => self.choice_opt = DialogYesNoOption::Yes, // Move yes/no with arrows
KeyCode::Right => self.choice_opt = DialogYesNoOption::No, // Move yes/no with arrows
KeyCode::Char(ch) => self.input_txt.push(ch),
KeyCode::Backspace => {
let _ = self.input_txt.pop();
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_popup_yesno
///
/// Input event handler for popup alert
fn handle_input_event_mode_popup_yesno(
&mut self,
ev: &InputEvent,
yes_cb: DialogCallback,
no_cb: DialogCallback,
) {
// If enter, close popup, otherwise move dialog option
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Enter => {
// @! Set input mode to Form BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.popup = None;
// Check if user selected yes or not
match self.choice_opt {
DialogYesNoOption::No => no_cb(self),
DialogYesNoOption::Yes => yes_cb(self),
}
// Reset choice option to yes
self.choice_opt = DialogYesNoOption::Yes;
}
KeyCode::Right => self.choice_opt = DialogYesNoOption::No, // Set to NO
KeyCode::Left => self.choice_opt = DialogYesNoOption::Yes, // Set to YES
_ => { /* Nothing to do */ }
}
}
}
}

View File

@@ -0,0 +1,651 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::{
AuthActivity, Context, DialogYesNoOption, FileTransferProtocol, InputField, InputForm, Popup,
};
use crate::utils::fmt::align_text_center;
// Ext
use std::string::ToString;
use tui::{
layout::{Constraint, Corner, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs},
};
use unicode_width::UnicodeWidthStr;
impl AuthActivity {
/// ### draw
///
/// Draw UI
pub(super) fn draw(&mut self) {
let mut ctx: Context = self.context.take().unwrap();
let _ = ctx.terminal.draw(|f| {
// Prepare chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Percentage(70), // Auth Form
Constraint::Percentage(30), // Bookmarks
]
.as_ref(),
)
.split(f.size());
// Create explorer chunks
let auth_chunks = Layout::default()
.constraints(
[
Constraint::Length(5),
Constraint::Length(1), // Version
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
]
.as_ref(),
)
.direction(Direction::Vertical)
.split(chunks[0]);
// Create bookmark chunks
let bookmark_chunks = Layout::default()
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)].as_ref())
.direction(Direction::Horizontal)
.split(chunks[1]);
// Draw header
f.render_widget(self.draw_header(), auth_chunks[0]);
f.render_widget(self.draw_new_version(), auth_chunks[1]);
// Draw input fields
f.render_widget(self.draw_remote_address(), auth_chunks[2]);
f.render_widget(self.draw_remote_port(), auth_chunks[3]);
f.render_widget(self.draw_protocol_select(), auth_chunks[4]);
f.render_widget(self.draw_protocol_username(), auth_chunks[5]);
f.render_widget(self.draw_protocol_password(), auth_chunks[6]);
// Draw footer
f.render_widget(self.draw_footer(), auth_chunks[7]);
// Set cursor
if let InputForm::AuthCredentials = self.input_form {
match self.selected_field {
InputField::Address => f.set_cursor(
auth_chunks[2].x + self.address.width() as u16 + 1,
auth_chunks[2].y + 1,
),
InputField::Port => f.set_cursor(
auth_chunks[3].x + self.port.width() as u16 + 1,
auth_chunks[3].y + 1,
),
InputField::Username => f.set_cursor(
auth_chunks[5].x + self.username.width() as u16 + 1,
auth_chunks[5].y + 1,
),
InputField::Password => f.set_cursor(
auth_chunks[6].x + self.password_placeholder.width() as u16 + 1,
auth_chunks[6].y + 1,
),
_ => {}
}
}
// Draw bookmarks
if let Some(tab) = self.draw_bookmarks_tab() {
let mut bookmarks_state: ListState = ListState::default();
bookmarks_state.select(Some(self.bookmarks_idx));
f.render_stateful_widget(tab, bookmark_chunks[0], &mut bookmarks_state);
}
if let Some(tab) = self.draw_recents_tab() {
let mut recents_state: ListState = ListState::default();
recents_state.select(Some(self.recents_idx));
f.render_stateful_widget(tab, bookmark_chunks[1], &mut recents_state);
}
// Draw popup
if let Some(popup) = &self.popup {
// Calculate popup size
let (width, height): (u16, u16) = match popup {
Popup::Alert(_, _) => (50, 10),
Popup::Help => (50, 70),
Popup::SaveBookmark => (20, 20),
Popup::YesNo(_, _, _) => (30, 10),
};
let popup_area: Rect = self.draw_popup_area(f.size(), width, height);
f.render_widget(Clear, popup_area); //this clears out the background
match popup {
Popup::Alert(color, txt) => f.render_widget(
self.draw_popup_alert(*color, txt.clone(), popup_area.width),
popup_area,
),
Popup::Help => f.render_widget(self.draw_popup_help(), popup_area),
Popup::SaveBookmark => {
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Input form
Constraint::Length(2), // Yes/No
]
.as_ref(),
)
.split(popup_area);
let (input, yes_no): (Paragraph, Tabs) = self.draw_popup_save_bookmark();
// Render parts
f.render_widget(input, popup_chunks[0]);
f.render_widget(yes_no, popup_chunks[1]);
// Set cursor
f.set_cursor(
popup_chunks[0].x + self.input_txt.width() as u16 + 1,
popup_chunks[0].y + 1,
)
}
Popup::YesNo(txt, _, _) => {
f.render_widget(self.draw_popup_yesno(txt.clone()), popup_area)
}
}
}
});
self.context = Some(ctx);
}
/// ### draw_remote_address
///
/// Draw remote address block
fn draw_remote_address(&self) -> Paragraph {
Paragraph::new(self.address.as_ref())
.style(match self.selected_field {
InputField::Address => Style::default().fg(Color::Yellow),
_ => Style::default(),
})
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Remote address"),
)
}
/// ### draw_remote_port
///
/// Draw remote port block
fn draw_remote_port(&self) -> Paragraph {
Paragraph::new(self.port.as_ref())
.style(match self.selected_field {
InputField::Port => Style::default().fg(Color::Cyan),
_ => Style::default(),
})
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Remote port"),
)
}
/// ### draw_protocol_select
///
/// Draw protocol select
fn draw_protocol_select(&self) -> Tabs {
let protocols: Vec<Spans> = vec![
Spans::from("SFTP"),
Spans::from("SCP"),
Spans::from("FTP"),
Spans::from("FTPS"),
];
let index: usize = match self.protocol {
FileTransferProtocol::Sftp => 0,
FileTransferProtocol::Scp => 1,
FileTransferProtocol::Ftp(ftps) => match ftps {
false => 2,
true => 3,
},
};
Tabs::new(protocols)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Protocol"),
)
.select(index)
.style(match self.selected_field {
InputField::Protocol => Style::default().fg(Color::Green),
_ => Style::default(),
})
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::Green)
.fg(Color::Black),
)
}
/// ### draw_protocol_username
///
/// Draw username block
fn draw_protocol_username(&self) -> Paragraph {
Paragraph::new(self.username.as_ref())
.style(match self.selected_field {
InputField::Username => Style::default().fg(Color::Magenta),
_ => Style::default(),
})
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Username"),
)
}
/// ### draw_protocol_password
///
/// Draw password block
fn draw_protocol_password(&mut self) -> Paragraph {
// Create password secret
self.password_placeholder = (0..self.password.width()).map(|_| "*").collect::<String>();
Paragraph::new(self.password_placeholder.as_ref())
.style(match self.selected_field {
InputField::Password => Style::default().fg(Color::LightBlue),
_ => Style::default(),
})
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Password"),
)
}
/// ### draw_header
///
/// Draw header
fn draw_header(&self) -> Paragraph {
Paragraph::new(" _____ ____ ____ ____ \n|_ _|__ _ __ _ __ ___ / ___| / ___| _ \\ \n | |/ _ \\ '__| '_ ` _ \\\\___ \\| | | |_) |\n | | __/ | | | | | | |___) | |___| __/ \n |_|\\___|_| |_| |_| |_|____/ \\____|_| \n")
.style(Style::default().fg(Color::White).add_modifier(Modifier::BOLD))
}
/// ### draw_new_version
///
/// Draw new version disclaimer
fn draw_new_version(&self) -> Paragraph {
let content: String = match self.new_version.as_ref() {
Some(ver) => format!("TermSCP {} is now available! Download it from <https://github.com/veeso/termscp/releases/latest>", ver),
None => String::new(),
};
Paragraph::new(content).style(
Style::default()
.fg(Color::Yellow)
.add_modifier(Modifier::BOLD),
)
}
/// ### draw_footer
///
/// Draw authentication page footer
fn draw_footer(&self) -> Paragraph {
// Write header
let (footer, h_style) = (
vec![
Span::raw("Press "),
Span::styled(
"<CTRL+H>",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Cyan),
),
Span::raw(" to show keybindings; "),
Span::styled(
"<CTRL+C>",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Cyan),
),
Span::raw(" to enter setup"),
],
Style::default().add_modifier(Modifier::BOLD),
);
let mut footer_text = Text::from(Spans::from(footer));
footer_text.patch_style(h_style);
Paragraph::new(footer_text)
}
/// ### draw_local_explorer
///
/// Draw local explorer list
fn draw_bookmarks_tab(&self) -> Option<List> {
self.bookmarks_client.as_ref()?;
let hosts: Vec<ListItem> = self
.bookmarks_list
.iter()
.map(|key: &String| {
let entry: (String, u16, FileTransferProtocol, String, _) = self
.bookmarks_client
.as_ref()
.unwrap()
.get_bookmark(key)
.unwrap();
ListItem::new(Span::from(format!(
"{} ({}://{}@{}:{})",
key,
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)))
})
.collect();
// Get colors to use; highlight element inverting fg/bg only when tab is active
let (fg, bg): (Color, Color) = match self.input_form {
InputForm::Bookmarks => (Color::Black, Color::LightGreen),
_ => (Color::Reset, Color::Reset),
};
Some(
List::new(hosts)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(match self.input_form {
InputForm::Bookmarks => Style::default().fg(Color::LightGreen),
_ => Style::default(),
})
.title("Bookmarks"),
)
.start_corner(Corner::TopLeft)
.highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)),
)
}
/// ### draw_local_explorer
///
/// Draw local explorer list
fn draw_recents_tab(&self) -> Option<List> {
self.bookmarks_client.as_ref()?;
let hosts: Vec<ListItem> = self
.recents_list
.iter()
.map(|key: &String| {
let entry: (String, u16, FileTransferProtocol, String) = self
.bookmarks_client
.as_ref()
.unwrap()
.get_recent(key)
.unwrap();
ListItem::new(Span::from(format!(
"{}://{}@{}:{}",
entry.2.to_string().to_lowercase(),
entry.3,
entry.0,
entry.1
)))
})
.collect();
// Get colors to use; highlight element inverting fg/bg only when tab is active
let (fg, bg): (Color, Color) = match self.input_form {
InputForm::Recents => (Color::Black, Color::LightBlue),
_ => (Color::Reset, Color::Reset),
};
Some(
List::new(hosts)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(match self.input_form {
InputForm::Recents => Style::default().fg(Color::LightBlue),
_ => Style::default(),
})
.title("Recent connections"),
)
.start_corner(Corner::TopLeft)
.highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD)),
)
}
/// ### draw_popup_area
///
/// Draw popup area
fn draw_popup_area(&self, area: Rect, width: u16, height: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - height) / 2),
Constraint::Percentage(height),
Constraint::Percentage((100 - height) / 2),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - width) / 2),
Constraint::Percentage(width),
Constraint::Percentage((100 - width) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1]
}
/// ### draw_popup_alert
///
/// Draw alert popup
fn draw_popup_alert(&self, color: Color, text: String, width: u16) -> List {
// Wraps texts
let message_rows = textwrap::wrap(text.as_str(), width as usize);
let mut lines: Vec<ListItem> = Vec::new();
for msg in message_rows.iter() {
lines.push(ListItem::new(Spans::from(align_text_center(msg, width))));
}
List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded)
.title("Alert"),
)
.start_corner(Corner::TopLeft)
.style(Style::default().fg(color))
}
/// ### draw_popup_input
///
/// Draw input popup
fn draw_popup_save_bookmark(&self) -> (Paragraph, Tabs) {
let input: Paragraph = Paragraph::new(self.input_txt.as_ref())
.style(Style::default().fg(Color::White))
.block(
Block::default()
.borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
.border_type(BorderType::Rounded)
.title("Save bookmark as..."),
);
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
let index: usize = match self.choice_opt {
DialogYesNoOption::Yes => 0,
DialogYesNoOption::No => 1,
};
let tabs: Tabs = Tabs::new(choices)
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
.border_type(BorderType::Rounded)
.title("Save password?"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::LightRed),
);
(input, tabs)
}
/// ### draw_popup_yesno
///
/// Draw yes/no select popup
fn draw_popup_yesno(&self, text: String) -> Tabs {
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
let index: usize = match self.choice_opt {
DialogYesNoOption::Yes => 0,
DialogYesNoOption::No => 1,
};
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(text),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
)
}
/// ### draw_popup_help
///
/// Draw authentication page help popup
fn draw_popup_help(&self) -> List {
// Write header
let cmds: Vec<ListItem> = vec![
ListItem::new(Spans::from(vec![
Span::styled(
"<ESC>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Quit TermSCP"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<TAB>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Switch input form and bookmarks"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<RIGHT/LEFT>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Change bookmark tab"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<UP/DOWN>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Move up/down in current tab"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<ENTER>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Submit"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<DEL>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Delete bookmark"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<E>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Delete selected bookmark"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+C>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Enter setup"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+H>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Show help"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+S>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Save bookmark"),
])),
];
List::new(cmds)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.border_type(BorderType::Rounded)
.title("Help"),
)
.start_corner(Corner::TopLeft)
}
}

View File

@@ -0,0 +1,291 @@
//! ## AuthActivity
//!
//! `auth_activity` is the module which implements the authentication activity
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Sub modules
mod bookmarks;
mod callbacks;
mod input;
mod layout;
// Dependencies
extern crate crossterm;
extern crate tui;
extern crate unicode_width;
// locals
use super::{Activity, Context};
use crate::filetransfer::FileTransferProtocol;
use crate::system::bookmarks_client::BookmarksClient;
use crate::system::config_client::ConfigClient;
use crate::system::environment;
use crate::utils::git;
// Includes
use crossterm::event::Event as InputEvent;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::path::PathBuf;
use tui::style::Color;
// Types
type DialogCallback = fn(&mut AuthActivity);
/// ### InputField
///
/// InputField describes the current input field to edit
#[derive(std::cmp::PartialEq)]
enum InputField {
Address,
Port,
Protocol,
Username,
Password,
}
/// ### DialogYesNoOption
///
/// Current yes/no dialog option
#[derive(std::cmp::PartialEq, Clone)]
enum DialogYesNoOption {
Yes,
No,
}
/// ### Popup
///
/// Popup describes the type of the popup displayed
#[derive(Clone)]
enum Popup {
Alert(Color, String), // Show a message displaying text with the provided color
Help, // Help page
SaveBookmark,
YesNo(String, DialogCallback, DialogCallback), // Yes, no callback
}
#[derive(std::cmp::PartialEq)]
/// ### InputForm
///
/// InputForm describes the selected input form
enum InputForm {
AuthCredentials,
Bookmarks,
Recents,
}
/// ### AuthActivity
///
/// AuthActivity is the data holder for the authentication activity
pub struct AuthActivity {
pub address: String,
pub port: String,
pub protocol: FileTransferProtocol,
pub username: String,
pub password: String,
pub submit: bool, // becomes true after user has submitted fields
pub quit: bool, // Becomes true if user has pressed esc
pub setup: bool, // Becomes true if user has requested setup
context: Option<Context>,
bookmarks_client: Option<BookmarksClient>,
config_client: Option<ConfigClient>,
selected_field: InputField, // Selected field in AuthCredentials Form
popup: Option<Popup>,
input_form: InputForm,
password_placeholder: String,
redraw: bool, // Should ui actually be redrawned?
input_txt: String, // Input text
choice_opt: DialogYesNoOption, // Dialog popup selected option
bookmarks_idx: usize, // Index of selected bookmark
bookmarks_list: Vec<String>, // List of bookmarks
recents_idx: usize, // Index of selected recent
recents_list: Vec<String>, // list of recents
// misc
new_version: Option<String>, // Contains new version of termscp
}
impl Default for AuthActivity {
fn default() -> Self {
Self::new()
}
}
impl AuthActivity {
/// ### new
///
/// Instantiates a new AuthActivity
pub fn new() -> AuthActivity {
AuthActivity {
address: String::new(),
port: String::from("22"),
protocol: FileTransferProtocol::Sftp,
username: String::new(),
password: String::new(),
submit: false,
quit: false,
setup: false,
context: None,
bookmarks_client: None,
config_client: None,
selected_field: InputField::Address,
popup: None,
input_form: InputForm::AuthCredentials,
password_placeholder: String::new(),
redraw: true, // True at startup
input_txt: String::new(),
choice_opt: DialogYesNoOption::Yes,
bookmarks_idx: 0,
bookmarks_list: Vec::new(),
recents_idx: 0,
recents_list: Vec::new(),
new_version: None,
}
}
/// ### init_config_client
///
/// Initialize config client
fn init_config_client(&mut self) {
// Get config dir
match environment::init_config_dir() {
Ok(config_dir) => {
if let Some(config_dir) = config_dir {
// Get config client paths
let (config_path, ssh_dir): (PathBuf, PathBuf) =
environment::get_config_paths(config_dir.as_path());
match ConfigClient::new(config_path.as_path(), ssh_dir.as_path()) {
Ok(cli) => {
// Set default protocol
self.protocol = cli.get_default_protocol();
// Set client
self.config_client = Some(cli);
}
Err(err) => {
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not initialize user configuration: {}", err),
))
}
}
}
}
Err(err) => {
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not initialize configuration directory: {}", err),
))
}
}
}
/// ### on_create
///
/// If enabled in configuration, check for updates from Github
fn check_for_updates(&mut self) {
if let Some(client) = self.config_client.as_ref() {
if client.get_check_for_updates() {
// Send request
match git::check_for_updates(env!("CARGO_PKG_VERSION")) {
Ok(version) => self.new_version = version,
Err(err) => {
// Report error
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not check for new updates: {}", err),
))
}
}
}
}
}
}
impl Activity for AuthActivity {
/// ### on_create
///
/// `on_create` is the function which must be called to initialize the activity.
/// `on_create` must initialize all the data structures used by the activity
/// Context is taken from activity manager and will be released only when activity is destroyed
fn on_create(&mut self, context: Context) {
// Set context
self.context = Some(context);
// Clear terminal
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
self.popup = None;
// Init bookmarks client
if self.bookmarks_client.is_none() {
self.init_bookmarks_client();
}
// init config client
if self.config_client.is_none() {
self.init_config_client();
}
// If check for updates is enabled, check for updates
self.check_for_updates();
}
/// ### on_draw
///
/// `on_draw` is the function which draws the graphical interface.
/// This function must be called at each tick to refresh the interface
fn on_draw(&mut self) {
// Context must be something
if self.context.is_none() {
return;
}
// Read one event
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
// Set redraw to true
self.redraw = true;
// Handle event
self.handle_input_event(&event);
}
// Redraw if necessary
if self.redraw {
// Draw
self.draw();
// Set redraw to false
self.redraw = false;
}
}
/// ### on_destroy
///
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
/// This function must be called once before terminating the activity.
/// This function finally releases the context
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
self.context.as_ref()?;
// Clear terminal and return
match self.context.take() {
Some(mut ctx) => {
ctx.clear_screen();
Some(ctx)
}
None => None,
}
}
}

View File

@@ -1,6 +1,10 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -19,10 +23,10 @@
*
*/
use super::{FileExplorerTab, FileTransferActivity, FsEntry, InputMode, LogLevel, PopupType};
// Locals
use super::{FileExplorerTab, FileTransferActivity, FsEntry, LogLevel};
// Ext
use std::path::PathBuf;
use tui::style::Color;
impl FileTransferActivity {
/// ### callback_nothing_to_do
@@ -40,7 +44,7 @@ impl FileTransferActivity {
// If path is relative, concat pwd
let abs_dir_path: PathBuf = match dir_path.is_relative() {
true => {
let mut d: PathBuf = self.context.as_ref().unwrap().local.pwd();
let mut d: PathBuf = self.local.wrkdir.clone();
d.push(dir_path);
d
}
@@ -51,19 +55,11 @@ impl FileTransferActivity {
FileExplorerTab::Remote => {
// If path is relative, concat pwd
let abs_dir_path: PathBuf = match dir_path.is_relative() {
true => match self.client.pwd() {
Ok(mut wkrdir) => {
wkrdir.push(dir_path);
wkrdir
}
Err(err) => {
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not retrieve current directory: {}", err),
));
return;
}
},
true => {
let mut wrkdir: PathBuf = self.remote.wrkdir.clone();
wrkdir.push(dir_path);
wrkdir
}
false => dir_path,
};
self.remote_changedir(abs_dir_path.as_path(), true);
@@ -71,6 +67,77 @@ impl FileTransferActivity {
}
}
/// ### callback_copy
///
/// Callback for COPY command (both from local and remote)
pub(super) fn callback_copy(&mut self, input: String) {
let dest_path: PathBuf = PathBuf::from(input);
match self.tab {
FileExplorerTab::Local => {
// Get selected entry
if self.local.get_current_file().is_some() {
let entry: FsEntry = self.local.get_current_file().unwrap().clone();
if let Some(ctx) = self.context.as_mut() {
match ctx.local.copy(&entry, dest_path.as_path()) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest_path.display()
)
.as_str(),
);
// Reload entries
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.local_scan(wrkdir.as_path());
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest_path.display(),
err
),
),
}
}
}
}
FileExplorerTab::Remote => {
// Get selected entry
if self.remote.get_current_file().is_some() {
let entry: FsEntry = self.remote.get_current_file().unwrap().clone();
match self.client.as_mut().copy(&entry, dest_path.as_path()) {
Ok(_) => {
self.log(
LogLevel::Info,
format!(
"Copied \"{}\" to \"{}\"",
entry.get_abs_path().display(),
dest_path.display()
)
.as_str(),
);
self.reload_remote_dir();
}
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not copy \"{}\" to \"{}\": {}",
entry.get_abs_path().display(),
dest_path.display(),
err
),
),
}
}
}
}
}
/// ### callback_mkdir
///
/// Callback for MKDIR command (supports both local and remote)
@@ -90,24 +157,24 @@ impl FileTransferActivity {
LogLevel::Info,
format!("Created directory \"{}\"", input).as_ref(),
);
let wrkdir: PathBuf = self.context.as_ref().unwrap().local.pwd();
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.local_scan(wrkdir.as_path());
}
Err(err) => {
// Report err
self.log(
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err).as_ref(),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not create directory \"{}\": {}", input, err),
));
);
}
}
}
FileExplorerTab::Remote => {
match self.client.mkdir(PathBuf::from(input.as_str()).as_path()) {
match self
.client
.as_mut()
.mkdir(PathBuf::from(input.as_str()).as_path())
{
Ok(_) => {
// Reload files
self.log(
@@ -118,14 +185,10 @@ impl FileTransferActivity {
}
Err(err) => {
// Report err
self.log(
self.log_and_alert(
LogLevel::Error,
format!("Could not create directory \"{}\": {}", input, err).as_ref(),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not create directory \"{}\": {}", input, err),
));
);
}
}
}
@@ -141,12 +204,12 @@ impl FileTransferActivity {
let mut dst_path: PathBuf = PathBuf::from(input);
// Check if path is relative
if dst_path.as_path().is_relative() {
let mut wrkdir: PathBuf = self.context.as_ref().unwrap().local.pwd();
let mut wrkdir: PathBuf = self.local.wrkdir.clone();
wrkdir.push(dst_path);
dst_path = wrkdir;
}
// Check if file entry exists
if let Some(entry) = self.local.files.get(self.local.index) {
if let Some(entry) = self.local.get_current_file() {
let full_path: PathBuf = entry.get_abs_path();
// Rename file or directory and report status as popup
match self
@@ -158,7 +221,8 @@ impl FileTransferActivity {
{
Ok(_) => {
// Reload files
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
let path: PathBuf = self.local.wrkdir.clone();
self.local_scan(path.as_path());
// Log
self.log(
LogLevel::Info,
@@ -171,35 +235,29 @@ impl FileTransferActivity {
);
}
Err(err) => {
self.log(
self.log_and_alert(
LogLevel::Error,
format!(
"Could not rename file \"{}\": {}",
full_path.display(),
err
)
.as_ref(),
),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not rename file: {}", err),
))
}
}
}
}
FileExplorerTab::Remote => {
// Check if file entry exists
if let Some(entry) = self.remote.files.get(self.remote.index) {
if let Some(entry) = self.remote.get_current_file() {
let full_path: PathBuf = entry.get_abs_path();
// Rename file or directory and report status as popup
let dst_path: PathBuf = PathBuf::from(input);
match self.client.rename(entry, dst_path.as_path()) {
match self.client.as_mut().rename(entry, dst_path.as_path()) {
Ok(_) => {
// Reload files
if let Ok(path) = self.client.pwd() {
self.remote_scan(path.as_path());
}
let path: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(path.as_path());
// Log
self.log(
LogLevel::Info,
@@ -212,19 +270,14 @@ impl FileTransferActivity {
);
}
Err(err) => {
self.log(
self.log_and_alert(
LogLevel::Error,
format!(
"Could not rename file \"{}\": {}",
full_path.display(),
err
)
.as_ref(),
),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not rename file: {}", err),
))
}
}
}
@@ -240,13 +293,14 @@ impl FileTransferActivity {
match self.tab {
FileExplorerTab::Local => {
// Check if file entry exists
if let Some(entry) = self.local.files.get(self.local.index) {
if let Some(entry) = self.local.get_current_file() {
let full_path: PathBuf = entry.get_abs_path();
// Delete file or directory and report status as popup
match self.context.as_mut().unwrap().local.remove(entry) {
Ok(_) => {
// Reload files
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
let p: PathBuf = self.local.wrkdir.clone();
self.local_scan(p.as_path());
// Log
self.log(
LogLevel::Info,
@@ -254,26 +308,21 @@ impl FileTransferActivity {
);
}
Err(err) => {
self.log(
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
full_path.display(),
err
)
.as_ref(),
),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not delete file: {}", err),
))
}
}
}
}
FileExplorerTab::Remote => {
// Check if file entry exists
if let Some(entry) = self.remote.files.get(self.remote.index) {
if let Some(entry) = self.remote.get_current_file() {
let full_path: PathBuf = entry.get_abs_path();
// Delete file
match self.client.remove(entry) {
@@ -285,19 +334,14 @@ impl FileTransferActivity {
);
}
Err(err) => {
self.log(
self.log_and_alert(
LogLevel::Error,
format!(
"Could not delete file \"{}\": {}",
full_path.display(),
err
)
.as_ref(),
),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not delete file: {}", err),
))
}
}
}
@@ -313,37 +357,132 @@ impl FileTransferActivity {
match self.tab {
FileExplorerTab::Local => {
// Get pwd
let wrkdir: PathBuf = match self.client.pwd() {
Ok(p) => p,
Err(err) => {
self.log(
LogLevel::Error,
format!("Could not get current remote path: {}", err).as_ref(),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not get current remote path: {}", err),
));
return;
}
};
let wrkdir: PathBuf = self.remote.wrkdir.clone();
// Get file and clone (due to mutable / immutable stuff...)
if self.local.files.get(self.local.index).is_some() {
let file: FsEntry = self.local.files.get(self.local.index).unwrap().clone();
if self.local.get_current_file().is_some() {
let file: FsEntry = self.local.get_current_file().unwrap().clone();
// Call upload; pass realfile, keep link name
self.filetransfer_send(&file.get_realfile(), wrkdir.as_path(), Some(input));
}
}
FileExplorerTab::Remote => {
// Get file and clone (due to mutable / immutable stuff...)
if self.remote.files.get(self.remote.index).is_some() {
let file: FsEntry = self.remote.files.get(self.remote.index).unwrap().clone();
if self.remote.get_current_file().is_some() {
let file: FsEntry = self.remote.get_current_file().unwrap().clone();
// Call upload; pass realfile, keep link name
self.filetransfer_recv(
&file.get_realfile(),
self.context.as_ref().unwrap().local.pwd().as_path(),
Some(input),
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.filetransfer_recv(&file.get_realfile(), wrkdir.as_path(), Some(input));
}
}
}
}
/// ### callback_new_file
///
/// Create a new file in current directory with `input` as name
pub(super) fn callback_new_file(&mut self, input: String) {
match self.tab {
FileExplorerTab::Local => {
// Check if file exists
let mut file_exists: bool = false;
for file in self.local.iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Create file
let file_path: PathBuf = PathBuf::from(input.as_str());
if let Some(ctx) = self.context.as_mut() {
if let Err(err) = ctx.local.open_file_write(file_path.as_path()) {
self.log_and_alert(
LogLevel::Error,
format!("Could not create file \"{}\": {}", file_path.display(), err),
);
}
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display()).as_str(),
);
// Reload files
let path: PathBuf = self.local.wrkdir.clone();
self.local_scan(path.as_path());
}
}
FileExplorerTab::Remote => {
// Check if file exists
let mut file_exists: bool = false;
for file in self.remote.iter_files_all() {
if input == file.get_name() {
file_exists = true;
}
}
if file_exists {
self.log_and_alert(
LogLevel::Warn,
format!("File \"{}\" already exists", input,),
);
return;
}
// Get path on remote
let file_path: PathBuf = PathBuf::from(input.as_str());
// Create file (on local)
match tempfile::NamedTempFile::new() {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!("Could not create tempfile: {}", err),
),
Ok(tfile) => {
// Stat tempfile
if let Some(ctx) = self.context.as_mut() {
let local_file: FsEntry = match ctx.local.stat(tfile.path()) {
Err(err) => {
self.log_and_alert(
LogLevel::Error,
format!("Could not stat tempfile: {}", err),
);
return;
}
Ok(f) => f,
};
if let FsEntry::File(local_file) = local_file {
// Create file
match self.client.send_file(&local_file, file_path.as_path()) {
Err(err) => self.log_and_alert(
LogLevel::Error,
format!(
"Could not create file \"{}\": {}",
file_path.display(),
err
),
),
Ok(writer) => {
// Finalize write
if let Err(err) = self.client.on_sent(writer) {
self.log_and_alert(
LogLevel::Warn,
format!("Could not finalize file: {}", err),
);
}
self.log(
LogLevel::Info,
format!("Created file \"{}\"", file_path.display())
.as_str(),
);
// Reload files
let path: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(path.as_path());
}
}
}
}
}
}
}
}

View File

@@ -1,6 +1,10 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -19,14 +23,17 @@
*
*/
// Deps
extern crate tempfile;
// Local
use super::{
DialogCallback, DialogYesNoOption, FileExplorerTab, FileTransferActivity, FsEntry, InputEvent,
InputField, InputMode, LogLevel, OnInputSubmitCallback, PopupType,
InputField, LogLevel, OnInputSubmitCallback, Popup,
};
use crate::fs::explorer::{FileExplorer, FileSorting};
// Ext
use crossterm::event::{KeyCode, KeyModifiers};
use std::path::PathBuf;
use tui::style::Color;
impl FileTransferActivity {
/// ### read_input_event
@@ -34,17 +41,11 @@ impl FileTransferActivity {
/// Read one event.
/// Returns whether at least one event has been handled
pub(super) fn read_input_event(&mut self) -> bool {
if let Ok(event) = self.context.as_ref().unwrap().input_hnd.read_event() {
// Iterate over input events
if let Some(event) = event {
// Handle event
self.handle_input_event(&event);
// Return true
true
} else {
// No event
false
}
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
// Handle event
self.handle_input_event(&event);
// Return true
true
} else {
// Error
false
@@ -54,16 +55,16 @@ impl FileTransferActivity {
/// ### handle_input_event
///
/// Handle input event based on current input mode
pub(super) fn handle_input_event(&mut self, ev: &InputEvent) {
fn handle_input_event(&mut self, ev: &InputEvent) {
// NOTE: this is necessary due to this <https://github.com/rust-lang/rust/issues/59159>
// NOTE: Do you want my opinion about that issue? It's a bs and doesn't make any sense.
let popup: Option<PopupType> = match &self.input_mode {
InputMode::Popup(ptype) => Some(ptype.clone()),
let popup: Option<Popup> = match &self.popup {
Some(ptype) => Some(ptype.clone()),
_ => None,
};
match &self.input_mode {
InputMode::Explorer => self.handle_input_event_mode_explorer(ev),
InputMode::Popup(_) => {
match &self.popup {
None => self.handle_input_event_mode_explorer(ev),
Some(_) => {
if let Some(popup) = popup {
self.handle_input_event_mode_popup(ev, popup);
}
@@ -74,7 +75,7 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_explorer
///
/// Input event handler for explorer mode
pub(super) fn handle_input_event_mode_explorer(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_explorer(&mut self, ev: &InputEvent) {
// Match input field
match self.input_field {
InputField::Explorer => match self.tab {
@@ -89,53 +90,40 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_explorer_tab_local
///
/// Input event handler for explorer mode when localhost tab is selected
pub(super) fn handle_input_event_mode_explorer_tab_local(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_explorer_tab_local(&mut self, ev: &InputEvent) {
// Match events
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Handle quit event
// Create quit prompt dialog
self.input_mode = self.create_disconnect_popup();
self.popup = Some(self.create_disconnect_popup());
}
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
KeyCode::Right => self.tab = FileExplorerTab::Remote, // <RIGHT> switch to right tab
KeyCode::Up => {
// Move index up; or move to the last element if 0
self.local.index = match self.local.index {
0 => self.local.files.len() - 1,
_ => self.local.index - 1,
};
// Decrement index
self.local.decr_index();
}
KeyCode::Down => {
// Move index down
if self.local.index + 1 < self.local.files.len() {
self.local.index += 1;
} else {
self.local.index = 0; // Move at the beginning of the list
}
// Increment index
self.local.incr_index();
}
KeyCode::PageUp => {
// Move index up (fast)
if self.local.index > 8 {
self.local.index -= 8; // Decrease by `8` if possible
} else {
self.local.index = 0; // Set to 0 otherwise
}
// Decrement index by 8
self.local.decr_index_by(8);
}
KeyCode::PageDown => {
// Move index down (fast)
if self.local.index + 8 >= self.local.files.len() {
// If overflows, set to size
self.local.index = self.local.files.len() - 1;
} else {
self.local.index += 8; // Increase by `8`
}
// Increment index by 8
self.local.incr_index_by(8);
}
KeyCode::Enter => {
// Match selected file
let local_files: Vec<FsEntry> = self.local.files.clone();
if let Some(entry) = local_files.get(self.local.index) {
let mut entry: Option<FsEntry> = None;
if let Some(e) = self.local.get_current_file() {
entry = Some(e.clone());
}
if let Some(entry) = entry {
// If directory, enter directory, otherwise check if symlink
match entry {
FsEntry::Directory(dir) => {
@@ -161,14 +149,16 @@ impl FileTransferActivity {
}
KeyCode::Delete => {
// Get file at index
if let Some(entry) = self.local.files.get(self.local.index) {
if let Some(entry) = self.local.get_current_file() {
// Get file name
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
};
// Default choice to NO for delete!
self.choice_opt = DialogYesNoOption::No;
// Show delete prompt
self.input_mode = InputMode::Popup(PopupType::YesNo(
self.popup = Some(Popup::YesNo(
format!("Delete file \"{}\"", file_name),
FileTransferActivity::callback_delete_fsentry,
FileTransferActivity::callback_nothing_to_do,
@@ -176,20 +166,40 @@ impl FileTransferActivity {
}
}
KeyCode::Char(ch) => match ch {
'q' | 'Q' => {
// Create quit prompt dialog
self.input_mode = self.create_quit_popup();
'a' | 'A' => {
// Toggle hidden files
self.local.toggle_hidden_files();
}
'b' | 'B' => {
// Choose file sorting type
self.popup = Some(Popup::FileSortingDialog);
}
'c' | 'C' => {
// Copy
self.popup = Some(Popup::Input(
String::from("Insert destination name"),
FileTransferActivity::callback_copy,
));
}
'd' | 'D' => {
// Make directory
self.popup = Some(Popup::Input(
String::from("Insert directory name"),
FileTransferActivity::callback_mkdir,
));
}
'e' | 'E' => {
// Get file at index
if let Some(entry) = self.local.files.get(self.local.index) {
if let Some(entry) = self.local.get_current_file() {
// Get file name
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
};
// Default choice to NO for delete!
self.choice_opt = DialogYesNoOption::No;
// Show delete prompt
self.input_mode = InputMode::Popup(PopupType::YesNo(
self.popup = Some(Popup::YesNo(
format!("Delete file \"{}\"", file_name),
FileTransferActivity::callback_delete_fsentry,
FileTransferActivity::callback_nothing_to_do,
@@ -199,29 +209,65 @@ impl FileTransferActivity {
'g' | 'G' => {
// Goto
// Show input popup
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Change working directory"),
FileTransferActivity::callback_change_directory,
));
}
'd' | 'D' => {
// Make directory
self.input_mode = InputMode::Popup(PopupType::Input(
String::from("Insert directory name"),
FileTransferActivity::callback_mkdir,
));
}
'h' | 'H' => {
// Show help
self.input_mode = InputMode::Popup(PopupType::Help);
self.popup = Some(Popup::Help);
}
'i' | 'I' => {
// Show file info
self.input_mode = InputMode::Popup(PopupType::FileInfo);
self.popup = Some(Popup::FileInfo);
}
'l' | 'L' => {
// Reload file entries
let pwd: PathBuf = self.local.wrkdir.clone();
self.local_scan(pwd.as_path());
}
'n' | 'N' => {
// New file
self.popup = Some(Popup::Input(
String::from("New file"),
Self::callback_new_file,
));
}
'o' | 'O' => {
// Edit local file
if self.local.get_current_file().is_some() {
// Clone entry due to mutable stuff...
let fsentry: FsEntry = self.local.get_current_file().unwrap().clone();
// Check if file
if fsentry.is_file() {
self.log(
LogLevel::Info,
format!(
"Opening file \"{}\"...",
fsentry.get_abs_path().display()
)
.as_str(),
);
// Edit file
match self.edit_local_file(fsentry.get_abs_path().as_path()) {
Ok(_) => {
// Reload directory
let pwd: PathBuf = self.local.wrkdir.clone();
self.local_scan(pwd.as_path());
}
Err(err) => self.log_and_alert(LogLevel::Error, err),
}
}
}
}
'q' | 'Q' => {
// Create quit prompt dialog
self.popup = Some(self.create_quit_popup());
}
'r' | 'R' => {
// Rename
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Insert new name"),
FileTransferActivity::callback_rename,
));
@@ -229,7 +275,7 @@ impl FileTransferActivity {
's' | 'S' => {
// Save as...
// Ask for input
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Save as..."),
FileTransferActivity::callback_save_as,
));
@@ -237,32 +283,18 @@ impl FileTransferActivity {
'u' | 'U' => {
// Go to parent directory
// Get pwd
let path: PathBuf = self.context.as_ref().unwrap().local.pwd();
let path: PathBuf = self.local.wrkdir.clone();
if let Some(parent) = path.as_path().parent() {
self.local_changedir(parent, true);
}
}
' ' => {
// Get pwd
let wrkdir: PathBuf = match self.client.pwd() {
Ok(p) => p,
Err(err) => {
self.log(
LogLevel::Error,
format!("Could not get current remote path: {}", err).as_ref(),
);
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not get current remote path: {}", err),
));
return;
}
};
let wrkdir: PathBuf = self.remote.wrkdir.clone();
// Get file and clone (due to mutable / immutable stuff...)
if self.local.files.get(self.local.index).is_some() {
let file: FsEntry =
self.local.files.get(self.local.index).unwrap().clone();
let name: String = file.get_name();
if self.local.get_current_file().is_some() {
let file: FsEntry = self.local.get_current_file().unwrap().clone();
let name: String = file.get_name().to_string();
// Call upload; pass realfile, keep link name
self.filetransfer_send(
&file.get_realfile(),
@@ -281,53 +313,40 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_explorer_tab_local
///
/// Input event handler for explorer mode when remote tab is selected
pub(super) fn handle_input_event_mode_explorer_tab_remote(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_explorer_tab_remote(&mut self, ev: &InputEvent) {
// Match events
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Handle quit event
// Create quit prompt dialog
self.input_mode = self.create_disconnect_popup();
self.popup = Some(self.create_disconnect_popup());
}
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
KeyCode::Left => self.tab = FileExplorerTab::Local, // <LEFT> switch to local tab
KeyCode::Up => {
// Move index up; or move to the last element if 0
self.remote.index = match self.remote.index {
0 => self.remote.files.len() - 1,
_ => self.remote.index - 1,
};
// Decrement index
self.remote.decr_index();
}
KeyCode::Down => {
// Move index down
if self.remote.index + 1 < self.remote.files.len() {
self.remote.index += 1;
} else {
self.remote.index = 0; // Move at the beginning of the list
}
// Increment index
self.remote.incr_index();
}
KeyCode::PageUp => {
// Move index up (fast)
if self.remote.index > 8 {
self.remote.index -= 8; // Decrease by `8` if possible
} else {
self.remote.index = 0; // Set to 0 otherwise
}
// Decrement index by 8
self.remote.decr_index_by(8);
}
KeyCode::PageDown => {
// Move index down (fast)
if self.remote.index + 8 >= self.remote.files.len() {
// If overflows, set to size
self.remote.index = self.remote.files.len() - 1;
} else {
self.remote.index += 8; // Increase by `8`
}
// Increment index by 8
self.remote.incr_index_by(8);
}
KeyCode::Enter => {
// Match selected file
let files: Vec<FsEntry> = self.remote.files.clone();
if let Some(entry) = files.get(self.remote.index) {
let mut entry: Option<FsEntry> = None;
if let Some(e) = self.remote.get_current_file() {
entry = Some(e.clone());
}
if let Some(entry) = entry {
// If directory, enter directory; if file, check if is symlink
match entry {
FsEntry::Directory(dir) => {
@@ -353,14 +372,16 @@ impl FileTransferActivity {
}
KeyCode::Delete => {
// Get file at index
if let Some(entry) = self.remote.files.get(self.remote.index) {
if let Some(entry) = self.remote.get_current_file() {
// Get file name
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
};
// Default choice to NO for delete!
self.choice_opt = DialogYesNoOption::No;
// Show delete prompt
self.input_mode = InputMode::Popup(PopupType::YesNo(
self.popup = Some(Popup::YesNo(
format!("Delete file \"{}\"", file_name),
FileTransferActivity::callback_delete_fsentry,
FileTransferActivity::callback_nothing_to_do,
@@ -368,20 +389,40 @@ impl FileTransferActivity {
}
}
KeyCode::Char(ch) => match ch {
'q' | 'Q' => {
// Create quit prompt dialog
self.input_mode = self.create_quit_popup();
'a' | 'A' => {
// Toggle hidden files
self.remote.toggle_hidden_files();
}
'b' | 'B' => {
// Choose file sorting type
self.popup = Some(Popup::FileSortingDialog);
}
'c' | 'C' => {
// Copy
self.popup = Some(Popup::Input(
String::from("Insert destination name"),
FileTransferActivity::callback_copy,
));
}
'd' | 'D' => {
// Make directory
self.popup = Some(Popup::Input(
String::from("Insert directory name"),
FileTransferActivity::callback_mkdir,
));
}
'e' | 'E' => {
// Get file at index
if let Some(entry) = self.remote.files.get(self.remote.index) {
if let Some(entry) = self.remote.get_current_file() {
// Get file name
let file_name: String = match entry {
FsEntry::Directory(dir) => dir.name.clone(),
FsEntry::File(file) => file.name.clone(),
};
// Default choice to NO for delete!
self.choice_opt = DialogYesNoOption::No;
// Show delete prompt
self.input_mode = InputMode::Popup(PopupType::YesNo(
self.popup = Some(Popup::YesNo(
format!("Delete file \"{}\"", file_name),
FileTransferActivity::callback_delete_fsentry,
FileTransferActivity::callback_nothing_to_do,
@@ -391,29 +432,63 @@ impl FileTransferActivity {
'g' | 'G' => {
// Goto
// Show input popup
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Change working directory"),
FileTransferActivity::callback_change_directory,
));
}
'd' | 'D' => {
// Make directory
self.input_mode = InputMode::Popup(PopupType::Input(
String::from("Insert directory name"),
FileTransferActivity::callback_mkdir,
));
}
'h' | 'H' => {
// Show help
self.input_mode = InputMode::Popup(PopupType::Help);
self.popup = Some(Popup::Help);
}
'i' | 'I' => {
// Show file info
self.input_mode = InputMode::Popup(PopupType::FileInfo);
self.popup = Some(Popup::FileInfo);
}
'l' | 'L' => {
// Reload file entries
self.reload_remote_dir();
}
'n' | 'N' => {
// New file
self.popup = Some(Popup::Input(
String::from("New file"),
Self::callback_new_file,
));
}
'o' | 'O' => {
// Edit remote file
if self.remote.get_current_file().is_some() {
// Clone entry due to mutable stuff...
let fsentry: FsEntry = self.remote.get_current_file().unwrap().clone();
// Check if file
if let FsEntry::File(file) = fsentry {
self.log(
LogLevel::Info,
format!("Opening file \"{}\"...", file.abs_path.display())
.as_str(),
);
// Edit file
match self.edit_remote_file(&file) {
Ok(_) => {
// Reload directory
let pwd: PathBuf = self.remote.wrkdir.clone();
self.remote_scan(pwd.as_path());
}
Err(err) => self.log_and_alert(LogLevel::Error, err),
}
// Put input mode back to normal
self.popup = None;
}
}
}
'q' | 'Q' => {
// Create quit prompt dialog
self.popup = Some(self.create_quit_popup());
}
'r' | 'R' => {
// Rename
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Insert new name"),
FileTransferActivity::callback_rename,
));
@@ -421,38 +496,29 @@ impl FileTransferActivity {
's' | 'S' => {
// Save as...
// Ask for input
self.input_mode = InputMode::Popup(PopupType::Input(
self.popup = Some(Popup::Input(
String::from("Save as..."),
FileTransferActivity::callback_save_as,
));
}
'u' | 'U' => {
// Go to parent directory
// Get pwd
match self.client.pwd() {
Ok(path) => {
if let Some(parent) = path.as_path().parent() {
self.remote_changedir(parent, true);
}
}
Err(err) => {
self.input_mode = InputMode::Popup(PopupType::Alert(
Color::Red,
format!("Could not change working directory: {}", err),
))
}
let path: PathBuf = self.remote.wrkdir.clone();
// Go to parent directory
if let Some(parent) = path.as_path().parent() {
self.remote_changedir(parent, true);
}
}
' ' => {
// Get file and clone (due to mutable / immutable stuff...)
if self.remote.files.get(self.remote.index).is_some() {
let file: FsEntry =
self.remote.files.get(self.remote.index).unwrap().clone();
let name: String = file.get_name();
if self.remote.get_current_file().is_some() {
let file: FsEntry = self.remote.get_current_file().unwrap().clone();
let name: String = file.get_name().to_string();
// Call upload; pass realfile, keep link name
let wrkdir: PathBuf = self.local.wrkdir.clone();
self.filetransfer_recv(
&file.get_realfile(),
self.context.as_ref().unwrap().local.pwd().as_path(),
wrkdir.as_path(),
Some(name),
);
}
@@ -467,7 +533,7 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_explorer_log
///
/// Input even handler for explorer mode when log tab is selected
pub(super) fn handle_input_event_mode_explorer_log(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_explorer_log(&mut self, ev: &InputEvent) {
// Match event
let records_block: usize = 16;
if let InputEvent::Key(key) = ev {
@@ -475,7 +541,7 @@ impl FileTransferActivity {
KeyCode::Esc => {
// Handle quit event
// Create quit prompt dialog
self.input_mode = self.create_disconnect_popup();
self.popup = Some(self.create_disconnect_popup());
}
KeyCode::Tab => self.switch_input_field(), // <TAB> switch tab
KeyCode::Down => {
@@ -514,7 +580,7 @@ impl FileTransferActivity {
KeyCode::Char(ch) => match ch {
'q' | 'Q' => {
// Create quit prompt dialog
self.input_mode = self.create_quit_popup();
self.popup = Some(self.create_quit_popup());
}
_ => { /* Nothing to do */ }
},
@@ -526,16 +592,17 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_explorer
///
/// Input event handler for popup mode. Handler is then based on Popup type
pub(super) fn handle_input_event_mode_popup(&mut self, ev: &InputEvent, popup: PopupType) {
fn handle_input_event_mode_popup(&mut self, ev: &InputEvent, popup: Popup) {
match popup {
PopupType::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev),
PopupType::FileInfo => self.handle_input_event_mode_popup_fileinfo(ev),
PopupType::Help => self.handle_input_event_mode_popup_help(ev),
PopupType::Fatal(_) => self.handle_input_event_mode_popup_fatal(ev),
PopupType::Input(_, cb) => self.handle_input_event_mode_popup_input(ev, cb),
PopupType::Progress(_) => self.handle_input_event_mode_popup_progress(ev),
PopupType::Wait(_) => self.handle_input_event_mode_popup_wait(ev),
PopupType::YesNo(_, yes_cb, no_cb) => {
Popup::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev),
Popup::FileInfo => self.handle_input_event_mode_popup_fileinfo(ev),
Popup::Fatal(_) => self.handle_input_event_mode_popup_fatal(ev),
Popup::FileSortingDialog => self.handle_input_event_mode_popup_file_sorting(ev),
Popup::Help => self.handle_input_event_mode_popup_help(ev),
Popup::Input(_, cb) => self.handle_input_event_mode_popup_input(ev, cb),
Popup::Progress(_) => self.handle_input_event_mode_popup_progress(ev),
Popup::Wait(_) => self.handle_input_event_mode_popup_wait(ev),
Popup::YesNo(_, yes_cb, no_cb) => {
self.handle_input_event_mode_popup_yesno(ev, yes_cb, no_cb)
}
}
@@ -544,12 +611,12 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_popup_alert
///
/// Input event handler for popup alert
pub(super) fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if let KeyCode::Enter = key.code {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Set input mode back to explorer
self.input_mode = InputMode::Explorer;
self.popup = None;
}
}
}
@@ -557,13 +624,61 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_popup_fileinfo
///
/// Input event handler for popup fileinfo
pub(super) fn handle_input_event_mode_popup_fileinfo(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_popup_fileinfo(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Set input mode back to explorer
self.popup = None;
}
}
}
/// ### handle_input_event_mode_popup_fatal
///
/// Input event handler for popup alert
fn handle_input_event_mode_popup_fatal(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Set quit to true; since a fatal error happened
self.disconnect();
}
}
}
/// ### handle_input_event_mode_popup_file_sorting
///
/// Handle input event for file sorting dialog popup
fn handle_input_event_mode_popup_file_sorting(&mut self, ev: &InputEvent) {
// Match key code
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Enter | KeyCode::Esc => {
// Set input mode back to explorer
self.input_mode = InputMode::Explorer;
KeyCode::Esc | KeyCode::Enter => {
// Exit
self.popup = None;
}
KeyCode::Right => {
// Update sorting mode
match self.tab {
FileExplorerTab::Local => {
Self::move_sorting_mode_opt_right(&mut self.local);
}
FileExplorerTab::Remote => {
Self::move_sorting_mode_opt_right(&mut self.remote);
}
}
}
KeyCode::Left => {
// Update sorting mode
match self.tab {
FileExplorerTab::Local => {
Self::move_sorting_mode_opt_left(&mut self.local);
}
FileExplorerTab::Remote => {
Self::move_sorting_mode_opt_left(&mut self.remote);
}
}
}
_ => { /* Nothing to do */ }
}
@@ -573,28 +688,12 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_popup_help
///
/// Input event handler for popup help
pub(super) fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Enter | KeyCode::Esc => {
// Set input mode back to explorer
self.input_mode = InputMode::Explorer;
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_popup_fatal
///
/// Input event handler for popup alert
pub(super) fn handle_input_event_mode_popup_fatal(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if let KeyCode::Enter = key.code {
// Set quit to true; since a fatal error happened
self.disconnect();
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Set input mode back to explorer
self.popup = None;
}
}
}
@@ -602,11 +701,7 @@ impl FileTransferActivity {
/// ### handle_input_event_mode_popup_input
///
/// Input event handler for input popup
pub(super) fn handle_input_event_mode_popup_input(
&mut self,
ev: &InputEvent,
cb: OnInputSubmitCallback,
) {
fn handle_input_event_mode_popup_input(&mut self, ev: &InputEvent, cb: OnInputSubmitCallback) {
// If enter, close popup, otherwise push chars to input
if let InputEvent::Key(key) = ev {
match key.code {
@@ -615,7 +710,7 @@ impl FileTransferActivity {
// Clear current input text
self.input_txt.clear();
// Set mode back to explorer
self.input_mode = InputMode::Explorer;
self.popup = None;
}
KeyCode::Enter => {
// Submit
@@ -623,7 +718,7 @@ impl FileTransferActivity {
// Clear current input text
self.input_txt.clear();
// Set mode back to explorer BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.input_mode = InputMode::Explorer;
self.popup = None;
// Call cb
cb(self, input_text);
}
@@ -636,10 +731,10 @@ impl FileTransferActivity {
}
}
/// ### handle_input_event_mode_explorer_alert
/// ### handle_input_event_mode_popup_progress
///
/// Input event handler for popup alert
pub(super) fn handle_input_event_mode_popup_progress(&mut self, ev: &InputEvent) {
fn handle_input_event_mode_popup_progress(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
if let KeyCode::Char(ch) = key.code {
// If is 'C' and CTRL
@@ -651,17 +746,17 @@ impl FileTransferActivity {
}
}
/// ### handle_input_event_mode_explorer_alert
/// ### handle_input_event_mode_popup_wait
///
/// Input event handler for popup alert
pub(super) fn handle_input_event_mode_popup_wait(&mut self, _ev: &InputEvent) {
fn handle_input_event_mode_popup_wait(&mut self, _ev: &InputEvent) {
// There's nothing you can do here I guess... maybe ctrl+c in the future idk
}
/// ### handle_input_event_mode_explorer_alert
/// ### handle_input_event_mode_popup_yesno
///
/// Input event handler for popup alert
pub(super) fn handle_input_event_mode_popup_yesno(
fn handle_input_event_mode_popup_yesno(
&mut self,
ev: &InputEvent,
yes_cb: DialogCallback,
@@ -672,7 +767,7 @@ impl FileTransferActivity {
match key.code {
KeyCode::Enter => {
// @! Set input mode to Explorer BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.input_mode = InputMode::Explorer;
self.popup = None;
// Check if user selected yes or not
match self.choice_opt {
DialogYesNoOption::No => no_cb(self),
@@ -687,4 +782,30 @@ impl FileTransferActivity {
}
}
}
/// ### move_sorting_mode_opt_left
///
/// Perform <LEFT> on file sorting dialog
fn move_sorting_mode_opt_left(explorer: &mut FileExplorer) {
let curr_sorting: FileSorting = explorer.get_file_sorting();
explorer.sort_by(match curr_sorting {
FileSorting::BySize => FileSorting::ByCreationTime,
FileSorting::ByCreationTime => FileSorting::ByModifyTime,
FileSorting::ByModifyTime => FileSorting::ByName,
FileSorting::ByName => FileSorting::BySize, // Wrap
});
}
/// ### move_sorting_mode_opt_left
///
/// Perform <RIGHT> on file sorting dialog
fn move_sorting_mode_opt_right(explorer: &mut FileExplorer) {
let curr_sorting: FileSorting = explorer.get_file_sorting();
explorer.sort_by(match curr_sorting {
FileSorting::ByName => FileSorting::ByModifyTime,
FileSorting::ByModifyTime => FileSorting::ByCreationTime,
FileSorting::ByCreationTime => FileSorting::BySize,
FileSorting::BySize => FileSorting::ByName, // Wrap
});
}
}

View File

@@ -1,6 +1,10 @@
//! ## FileTransferActivity
//!
//! `filetransfer_activiy` is the module which implements the Filetransfer activity, which is the main activity afterall
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -19,24 +23,28 @@
*
*/
// Deps
extern crate bytesize;
extern crate hostname;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
extern crate users;
// Local
use super::{
Context, DialogYesNoOption, FileExplorerTab, FileTransferActivity, FsEntry, InputField,
InputMode, LogLevel, LogRecord, PopupType,
LogLevel, LogRecord, Popup,
};
use crate::utils::time_to_str;
use crate::fs::explorer::{FileExplorer, FileSorting};
use crate::utils::fmt::{align_text_center, fmt_time};
// Ext
use bytesize::ByteSize;
use std::path::{Path, PathBuf};
use tui::{
layout::{Constraint, Corner, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Spans},
widgets::{Block, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Tabs},
widgets::{
Block, BorderType, Borders, Clear, Gauge, List, ListItem, ListState, Paragraph, Tabs,
},
};
use unicode_width::UnicodeWidthStr;
#[cfg(any(target_os = "unix", target_os = "macos", target_os = "linux"))]
@@ -48,7 +56,6 @@ impl FileTransferActivity {
/// Draw UI
pub(super) fn draw(&mut self) {
let mut ctx: Context = self.context.take().unwrap();
let local_wrkdir: PathBuf = ctx.local.pwd();
let _ = ctx.terminal.draw(|f| {
// Prepare chunks
let chunks = Layout::default()
@@ -56,8 +63,8 @@ impl FileTransferActivity {
.margin(1)
.constraints(
[
Constraint::Length(20), // Explorer
Constraint::Length(16), // Log
Constraint::Percentage(70), // Explorer
Constraint::Percentage(30), // Log
]
.as_ref(),
)
@@ -69,23 +76,18 @@ impl FileTransferActivity {
.split(chunks[0]);
// Set localhost state
let mut localhost_state: ListState = ListState::default();
localhost_state.select(Some(self.local.index));
localhost_state.select(Some(self.local.get_relative_index()));
// Set remote state
let mut remote_state: ListState = ListState::default();
remote_state.select(Some(self.remote.index));
remote_state.select(Some(self.remote.get_relative_index()));
// Draw tabs
f.render_stateful_widget(
self.draw_local_explorer(local_wrkdir, tabs_chunks[0].width),
self.draw_local_explorer(tabs_chunks[0].width),
tabs_chunks[0],
&mut localhost_state,
);
// Get pwd
let remote_wrkdir: PathBuf = match self.client.pwd() {
Ok(p) => p,
Err(_) => PathBuf::from("/"),
};
f.render_stateful_widget(
self.draw_remote_explorer(remote_wrkdir, tabs_chunks[1].width),
self.draw_remote_explorer(tabs_chunks[1].width),
tabs_chunks[1],
&mut remote_state,
);
@@ -99,32 +101,36 @@ impl FileTransferActivity {
&mut log_state,
);
// Draw popup
if let InputMode::Popup(popup) = &self.input_mode {
if let Some(popup) = &self.popup {
// Calculate popup size
let (width, height): (u16, u16) = match popup {
PopupType::Alert(_, _) => (50, 10),
PopupType::Fatal(_) => (50, 10),
PopupType::FileInfo => (50, 50),
PopupType::Help => (50, 70),
PopupType::Input(_, _) => (40, 10),
PopupType::Progress(_) => (40, 10),
PopupType::Wait(_) => (50, 10),
PopupType::YesNo(_, _, _) => (30, 10),
Popup::Alert(_, _) => (50, 10),
Popup::Fatal(_) => (50, 10),
Popup::FileInfo => (50, 50),
Popup::FileSortingDialog => (50, 10),
Popup::Help => (50, 80),
Popup::Input(_, _) => (40, 10),
Popup::Progress(_) => (40, 10),
Popup::Wait(_) => (50, 10),
Popup::YesNo(_, _, _) => (30, 10),
};
let popup_area: Rect = self.draw_popup_area(f.size(), width, height);
f.render_widget(Clear, popup_area); //this clears out the background
match popup {
PopupType::Alert(color, txt) => f.render_widget(
Popup::Alert(color, txt) => f.render_widget(
self.draw_popup_alert(*color, txt.clone(), popup_area.width),
popup_area,
),
PopupType::Fatal(txt) => f.render_widget(
Popup::Fatal(txt) => f.render_widget(
self.draw_popup_fatal(txt.clone(), popup_area.width),
popup_area,
),
PopupType::FileInfo => f.render_widget(self.draw_popup_fileinfo(), popup_area),
PopupType::Help => f.render_widget(self.draw_popup_help(), popup_area),
PopupType::Input(txt, _) => {
Popup::FileInfo => f.render_widget(self.draw_popup_fileinfo(), popup_area),
Popup::FileSortingDialog => {
f.render_widget(self.draw_popup_file_sorting_dialog(), popup_area)
}
Popup::Help => f.render_widget(self.draw_popup_help(), popup_area),
Popup::Input(txt, _) => {
f.render_widget(self.draw_popup_input(txt.clone()), popup_area);
// Set cursor
f.set_cursor(
@@ -132,14 +138,14 @@ impl FileTransferActivity {
popup_area.y + 1,
)
}
PopupType::Progress(txt) => {
Popup::Progress(txt) => {
f.render_widget(self.draw_popup_progress(txt.clone()), popup_area)
}
PopupType::Wait(txt) => f.render_widget(
Popup::Wait(txt) => f.render_widget(
self.draw_popup_wait(txt.clone(), popup_area.width),
popup_area,
),
PopupType::YesNo(txt, _, _) => {
Popup::YesNo(txt, _, _) => {
f.render_widget(self.draw_popup_yesno(txt.clone()), popup_area)
}
}
@@ -151,7 +157,7 @@ impl FileTransferActivity {
/// ### draw_local_explorer
///
/// Draw local explorer list
pub(super) fn draw_local_explorer(&self, local_wrkdir: PathBuf, width: u16) -> List {
pub(super) fn draw_local_explorer(&self, width: u16) -> List {
let hostname: String = match hostname::get() {
Ok(h) => {
let hostname: String = h.as_os_str().to_string_lossy().to_string();
@@ -162,17 +168,21 @@ impl FileTransferActivity {
};
let files: Vec<ListItem> = self
.local
.files
.iter()
.map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry))))
.iter_files()
.map(|entry: &FsEntry| ListItem::new(Span::from(self.local.fmt_file(entry))))
.collect();
// Get colors to use; highlight element inverting fg/bg only when tab is active
let (fg, bg): (Color, Color) = match self.tab {
FileExplorerTab::Local => (Color::Black, Color::LightYellow),
_ => (Color::LightYellow, Color::Reset),
};
List::new(files)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(match self.input_field {
InputField::Explorer => match self.tab {
FileExplorerTab::Local => Style::default().fg(Color::Yellow),
FileExplorerTab::Local => Style::default().fg(Color::LightYellow),
_ => Style::default(),
},
_ => Style::default(),
@@ -181,7 +191,7 @@ impl FileTransferActivity {
"{}:{} ",
hostname,
FileTransferActivity::elide_wrkdir_path(
local_wrkdir.as_path(),
self.local.wrkdir.as_path(),
hostname.as_str(),
width
)
@@ -189,24 +199,23 @@ impl FileTransferActivity {
)),
)
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::LightYellow)
.add_modifier(Modifier::BOLD),
)
.highlight_style(Style::default().fg(fg).bg(bg).add_modifier(Modifier::BOLD))
}
/// ### draw_remote_explorer
///
/// Draw remote explorer list
pub(super) fn draw_remote_explorer(&self, remote_wrkdir: PathBuf, width: u16) -> List {
pub(super) fn draw_remote_explorer(&self, width: u16) -> List {
let files: Vec<ListItem> = self
.remote
.files
.iter()
.map(|entry: &FsEntry| ListItem::new(Span::from(format!("{}", entry))))
.iter_files()
.map(|entry: &FsEntry| ListItem::new(Span::from(self.remote.fmt_file(entry))))
.collect();
// Get colors to use; highlight element inverting fg/bg only when tab is active
let (fg, bg): (Color, Color) = match self.tab {
FileExplorerTab::Remote => (Color::Black, Color::LightBlue),
_ => (Color::LightBlue, Color::Reset),
};
List::new(files)
.block(
Block::default()
@@ -222,7 +231,7 @@ impl FileTransferActivity {
"{}:{} ",
self.params.address,
FileTransferActivity::elide_wrkdir_path(
remote_wrkdir.as_path(),
self.remote.wrkdir.as_path(),
self.params.address.as_str(),
width
)
@@ -230,12 +239,7 @@ impl FileTransferActivity {
)),
)
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.bg(Color::LightBlue)
.fg(Color::Black)
.add_modifier(Modifier::BOLD),
)
.highlight_style(Style::default().bg(bg).fg(fg).add_modifier(Modifier::BOLD))
}
/// ### draw_log_list
@@ -334,15 +338,14 @@ impl FileTransferActivity {
let message_rows = textwrap::wrap(text.as_str(), width as usize);
let mut lines: Vec<ListItem> = Vec::new();
for msg in message_rows.iter() {
lines.push(ListItem::new(Spans::from(
FileTransferActivity::align_text_center(msg, width),
)));
lines.push(ListItem::new(Spans::from(align_text_center(msg, width))));
}
List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded)
.title("Alert"),
)
.start_corner(Corner::TopLeft)
@@ -357,27 +360,69 @@ impl FileTransferActivity {
let message_rows = textwrap::wrap(text.as_str(), width as usize);
let mut lines: Vec<ListItem> = Vec::new();
for msg in message_rows.iter() {
lines.push(ListItem::new(Spans::from(
FileTransferActivity::align_text_center(msg, width),
)));
lines.push(ListItem::new(Spans::from(align_text_center(msg, width))));
}
List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::Red))
.border_type(BorderType::Rounded)
.title("Fatal error"),
)
.start_corner(Corner::TopLeft)
.style(Style::default().fg(Color::Red))
}
/// ### draw_popup_file_sorting_dialog
///
/// Draw FileSorting mode select popup
pub(super) fn draw_popup_file_sorting_dialog(&self) -> Tabs {
let choices: Vec<Spans> = vec![
Spans::from("Name"),
Spans::from("Modify time"),
Spans::from("Creation time"),
Spans::from("Size"),
];
let explorer: &FileExplorer = match self.tab {
FileExplorerTab::Local => &self.local,
FileExplorerTab::Remote => &self.remote,
};
let index: usize = match explorer.get_file_sorting() {
FileSorting::ByCreationTime => 2,
FileSorting::ByModifyTime => 1,
FileSorting::ByName => 0,
FileSorting::BySize => 3,
};
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Sort files by"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.bg(Color::LightMagenta)
.fg(Color::DarkGray),
)
}
/// ### draw_popup_input
///
/// Draw input popup
pub(super) fn draw_popup_input(&self, text: String) -> Paragraph {
Paragraph::new(self.input_txt.as_ref())
.style(Style::default().fg(Color::White))
.block(Block::default().borders(Borders::ALL).title(text))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(text),
)
}
/// ### draw_popup_progress
@@ -385,16 +430,22 @@ impl FileTransferActivity {
/// Draw progress popup
pub(super) fn draw_popup_progress(&self, text: String) -> Gauge {
// Calculate ETA
let elapsed_secs: u64 = self.transfer.started.elapsed().as_secs();
let eta: String = match self.transfer.progress as u64 {
0 => String::from("--:--"), // NOTE: would divide by 0 :D
_ => {
let elapsed_secs: u64 = self.transfer.started.elapsed().as_secs();
let eta: u64 =
((elapsed_secs * 100) / (self.transfer.progress as u64)) - elapsed_secs;
format!("{:0width$}:{:0width$}", (eta / 60), (eta % 60), width = 2)
}
};
let label = format!("{:.2}% - ETA {}", self.transfer.progress, eta);
// Calculate bytes/s
let label = format!(
"{:.2}% - ETA {} ({}/s)",
self.transfer.progress,
eta,
ByteSize(self.transfer.bytes_per_second())
);
Gauge::default()
.block(Block::default().borders(Borders::ALL).title(text))
.gauge_style(
@@ -415,15 +466,14 @@ impl FileTransferActivity {
let message_rows = textwrap::wrap(text.as_str(), width as usize);
let mut lines: Vec<ListItem> = Vec::new();
for msg in message_rows.iter() {
lines.push(ListItem::new(Spans::from(
FileTransferActivity::align_text_center(msg, width),
)));
lines.push(ListItem::new(Spans::from(align_text_center(msg, width))));
}
List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::White))
.border_type(BorderType::Rounded)
.title("Please wait"),
)
.start_corner(Corner::TopLeft)
@@ -440,7 +490,12 @@ impl FileTransferActivity {
DialogYesNoOption::No => 1,
};
Tabs::new(choices)
.block(Block::default().borders(Borders::ALL).title(text))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(text),
)
.select(index)
.style(Style::default())
.highlight_style(
@@ -459,12 +514,12 @@ impl FileTransferActivity {
let fsentry: Option<&FsEntry> = match self.tab {
FileExplorerTab::Local => {
// Get selected file
match self.local.files.get(self.local.index) {
match self.local.get_current_file() {
Some(entry) => Some(entry),
None => None,
}
}
FileExplorerTab::Remote => match self.remote.files.get(self.remote.index) {
FileExplorerTab::Remote => match self.remote.get_current_file() {
Some(entry) => Some(entry),
None => None,
},
@@ -474,11 +529,10 @@ impl FileTransferActivity {
Some(fsentry) => {
// Get name and path
let abs_path: PathBuf = fsentry.get_abs_path();
let name: String = fsentry.get_name();
let ctime: String = time_to_str(fsentry.get_creation_time(), "%b %d %Y %H:%M:%S");
let atime: String =
time_to_str(fsentry.get_last_access_time(), "%b %d %Y %H:%M:%S");
let mtime: String = time_to_str(fsentry.get_creation_time(), "%b %d %Y %H:%M:%S");
let name: String = fsentry.get_name().to_string();
let ctime: String = fmt_time(fsentry.get_creation_time(), "%b %d %Y %H:%M:%S");
let atime: String = fmt_time(fsentry.get_last_access_time(), "%b %d %Y %H:%M:%S");
let mtime: String = fmt_time(fsentry.get_creation_time(), "%b %d %Y %H:%M:%S");
let (bsize, size): (ByteSize, usize) =
(ByteSize(fsentry.get_size() as u64), fsentry.get_size());
let user: Option<u32> = fsentry.get_user();
@@ -605,6 +659,7 @@ impl FileTransferActivity {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.border_type(BorderType::Rounded)
.title(file_name),
)
.start_corner(Corner::TopLeft)
@@ -624,7 +679,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("disconnect"),
Span::raw("Disconnect"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -654,7 +709,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("change explorer tab"),
Span::raw("Change explorer tab"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -664,7 +719,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("move up/down in list"),
Span::raw("Move up/down in list"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -674,7 +729,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("scroll up/down in list quickly"),
Span::raw("Scroll up/down in list quickly"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -684,7 +739,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("enter directory"),
Span::raw("Enter directory"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -694,7 +749,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("upload/download file"),
Span::raw("Upload/download file"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -704,7 +759,37 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("delete file"),
Span::raw("Delete file"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<A>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Toggle hidden files"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<B>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Change file sorting mode"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<C>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Copy"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -714,7 +799,17 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("make directory"),
Span::raw("Make directory"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<E>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Same as <DEL>"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -724,7 +819,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("goto path"),
Span::raw("Goto path"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -734,7 +829,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("show help"),
Span::raw("Show help"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -744,7 +839,37 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("show info about the selected file or directory"),
Span::raw("Show info about the selected file or directory"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<L>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Reload directory content"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<N>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("New file"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<O>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Open text file"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -764,7 +889,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("rename file"),
Span::raw("Rename file"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -774,7 +899,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("go to parent directory"),
Span::raw("Go to parent directory"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
@@ -784,7 +909,7 @@ impl FileTransferActivity {
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("abort current file transfer"),
Span::raw("Abort current file transfer"),
])),
];
List::new(cmds)
@@ -792,26 +917,12 @@ impl FileTransferActivity {
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.border_type(BorderType::Rounded)
.title("Help"),
)
.start_corner(Corner::TopLeft)
}
/// align_text_center
///
/// Align text to center for a given width
fn align_text_center(text: &str, width: u16) -> String {
let indent_size: usize = match (width as usize) >= text.len() {
// NOTE: The check prevents underflow
true => (width as usize - text.len()) / 2,
false => 0,
};
textwrap::indent(
text,
(0..indent_size).map(|_| " ").collect::<String>().as_str(),
)
}
/// ### elide_wrkdir_path
///
/// Elide working directory path if longer than width + host.len

View File

@@ -1,6 +1,6 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -19,7 +19,14 @@
*
*/
use super::{FileTransferActivity, InputField, InputMode, LogLevel, LogRecord, PopupType};
// Locals
use super::{Color, ConfigClient, FileTransferActivity, InputField, LogLevel, LogRecord, Popup};
use crate::fs::explorer::{builder::FileExplorerBuilder, FileExplorer, FileSorting, GroupDirs};
use crate::system::environment;
use crate::system::sshkey_storage::SshKeyStorage;
// Ext
use std::env;
use std::path::PathBuf;
impl FileTransferActivity {
/// ### log
@@ -38,26 +45,40 @@ impl FileTransferActivity {
self.log_index = 0;
}
/// ### create_quit_popup
/// ### log_and_alert
///
/// Create quit popup input mode (since must be shared between different input handlers)
pub(super) fn create_disconnect_popup(&mut self) -> InputMode {
InputMode::Popup(PopupType::YesNo(
String::from("Are you sure you want to disconnect?"),
FileTransferActivity::disconnect,
FileTransferActivity::callback_nothing_to_do,
))
/// Add message to log events and also display it as an alert
pub(super) fn log_and_alert(&mut self, level: LogLevel, msg: String) {
// Set input mode
let color: Color = match level {
LogLevel::Error => Color::Red,
LogLevel::Info => Color::Green,
LogLevel::Warn => Color::Yellow,
};
self.log(level, msg.as_str());
self.popup = Some(Popup::Alert(color, msg));
}
/// ### create_quit_popup
///
/// Create quit popup input mode (since must be shared between different input handlers)
pub(super) fn create_quit_popup(&mut self) -> InputMode {
InputMode::Popup(PopupType::YesNo(
pub(super) fn create_disconnect_popup(&mut self) -> Popup {
Popup::YesNo(
String::from("Are you sure you want to disconnect?"),
FileTransferActivity::disconnect,
FileTransferActivity::callback_nothing_to_do,
)
}
/// ### create_quit_popup
///
/// Create quit popup input mode (since must be shared between different input handlers)
pub(super) fn create_quit_popup(&mut self) -> Popup {
Popup::YesNo(
String::from("Are you sure you want to quit?"),
FileTransferActivity::disconnect_and_quit,
FileTransferActivity::callback_nothing_to_do,
))
)
}
/// ### switch_input_field
@@ -70,17 +91,65 @@ impl FileTransferActivity {
}
}
/// ### set_progress
/// ### init_config_client
///
/// Calculate progress percentage based on current progress
pub(super) fn set_progress(&mut self, it: usize, sz: usize) {
let mut prog: f64 = ((it as f64) * 100.0) / (sz as f64);
// Check value
if prog > 100.0 {
prog = 100.0;
} else if prog < 0.0 {
prog = 0.0;
/// Initialize configuration client if possible.
/// This function doesn't return errors.
pub(super) fn init_config_client() -> Option<ConfigClient> {
match environment::init_config_dir() {
Ok(termscp_dir) => match termscp_dir {
Some(termscp_dir) => {
// Make configuration file path and ssh keys path
let (config_path, ssh_keys_path): (PathBuf, PathBuf) =
environment::get_config_paths(termscp_dir.as_path());
match ConfigClient::new(config_path.as_path(), ssh_keys_path.as_path()) {
Ok(config_client) => Some(config_client),
Err(_) => None,
}
}
None => None,
},
Err(_) => None,
}
}
/// ### make_ssh_storage
///
/// Make ssh storage from `ConfigClient` if possible, empty otherwise
pub(super) fn make_ssh_storage(cli: Option<&ConfigClient>) -> SshKeyStorage {
match cli {
Some(cli) => SshKeyStorage::storage_from_config(cli),
None => SshKeyStorage::empty(),
}
}
/// ### build_explorer
///
/// Build explorer reading configuration from `ConfigClient`
pub(super) fn build_explorer(cli: Option<&ConfigClient>) -> FileExplorer {
match &cli {
Some(cli) => FileExplorerBuilder::new() // Build according to current configuration
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(cli.get_group_dirs())
.with_hidden_files(cli.get_show_hidden_files())
.with_stack_size(16)
.with_formatter(cli.get_file_fmt().as_deref())
.build(),
None => FileExplorerBuilder::new() // Build default
.with_file_sorting(FileSorting::ByName)
.with_group_dirs(Some(GroupDirs::First))
.with_stack_size(16)
.build(),
}
}
/// ### setup_text_editor
///
/// Set text editor to use
pub(super) fn setup_text_editor(&self) {
if let Some(config_cli) = &self.config_cli {
// Set text editor
env::set_var("EDITOR", config_cli.get_text_editor());
}
self.transfer.progress = prog;
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -39,21 +39,20 @@ extern crate unicode_width;
// locals
use super::{Activity, Context};
use crate::filetransfer::FileTransferProtocol;
// File transfer
use crate::filetransfer::ftp_transfer::FtpFileTransfer;
use crate::filetransfer::scp_transfer::ScpFileTransfer;
use crate::filetransfer::sftp_transfer::SftpFileTransfer;
use crate::filetransfer::FileTransfer;
use crate::filetransfer::{FileTransfer, FileTransferProtocol};
use crate::fs::explorer::FileExplorer;
use crate::fs::FsEntry;
use crate::system::config_client::ConfigClient;
// Includes
use chrono::{DateTime, Local};
use crossterm::event::Event as InputEvent;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::collections::VecDeque;
use std::path::{Path, PathBuf};
use std::path::PathBuf;
use std::time::Instant;
use tui::style::Color;
@@ -70,6 +69,7 @@ pub struct FileTransferParams {
pub protocol: FileTransferProtocol,
pub username: Option<String>,
pub password: Option<String>,
pub entry_directory: Option<PathBuf>,
}
/// ### InputField
@@ -90,14 +90,15 @@ enum DialogYesNoOption {
No,
}
/// ## PopupType
/// ## Popup
///
/// PopupType describes the type of popup
/// Popup describes the type of popup
#[derive(Clone)]
enum PopupType {
enum Popup {
Alert(Color, String), // Block color; Block text
Fatal(String), // Must quit after being hidden
FileInfo, // Show info about current file
FileSortingDialog, // Dialog for choosing file sorting type
Help, // Show Help
Input(String, OnInputSubmitCallback), // Input description; Callback for submit
Progress(String), // Progress block text
@@ -105,67 +106,6 @@ enum PopupType {
YesNo(String, DialogCallback, DialogCallback), // Yes, no callback
}
/// ## InputMode
///
/// InputMode describes the current input mode
/// Each input mode handle the input events in a different way
#[derive(Clone)]
enum InputMode {
Explorer,
Popup(PopupType),
}
/// ## FileExplorer
///
/// File explorer states
struct FileExplorer {
pub index: usize,
pub files: Vec<FsEntry>,
dirstack: VecDeque<PathBuf>,
}
impl FileExplorer {
/// ### new
///
/// Instantiates a new FileExplorer
pub fn new() -> FileExplorer {
FileExplorer {
index: 0,
files: Vec::new(),
dirstack: VecDeque::with_capacity(16),
}
}
/// ### pushd
///
/// push directory to stack
pub fn pushd(&mut self, dir: &Path) {
// Check if stack overflows the size
if self.dirstack.len() + 1 > 16 {
self.dirstack.pop_back(); // Start cleaning events from back
}
// Eventually push front the new record
self.dirstack.push_front(PathBuf::from(dir));
}
/// ### popd
///
/// Pop directory from the stack and return the directory
pub fn popd(&mut self) -> Option<PathBuf> {
self.dirstack.pop_front()
}
/// ### sort_files_by_name
///
/// Sort explorer files by their name
pub fn sort_files_by_name(&mut self) {
self.files.sort_by_key(|x: &FsEntry| match x {
FsEntry::Directory(dir) => dir.name.as_str().to_lowercase(),
FsEntry::File(file) => file.name.as_str().to_lowercase(),
});
}
}
/// ## FileExplorerTab
///
/// File explorer tab
@@ -209,9 +149,11 @@ impl LogRecord {
///
/// TransferStates contains the states related to the transfer process
struct TransferStates {
pub progress: f64, // Current read/write progress (percentage)
pub started: Instant, // Instant the transfer process started
pub aborted: bool, // Describes whether the transfer process has been aborted
pub progress: f64, // Current read/write progress (percentage)
pub started: Instant, // Instant the transfer process started
pub aborted: bool, // Describes whether the transfer process has been aborted
pub bytes_written: usize, // Bytes written during transfer
pub bytes_total: usize, // Total bytes to write
}
impl TransferStates {
@@ -223,6 +165,8 @@ impl TransferStates {
progress: 0.0,
started: Instant::now(),
aborted: false,
bytes_written: 0,
bytes_total: 0,
}
}
@@ -233,6 +177,40 @@ impl TransferStates {
self.progress = 0.0;
self.started = Instant::now();
self.aborted = false;
self.bytes_written = 0;
self.bytes_total = 0;
}
/// ### set_progress
///
/// Calculate progress percentage based on current progress
pub fn set_progress(&mut self, w: usize, sz: usize) {
self.bytes_written = w;
self.bytes_total = sz;
let mut prog: f64 = ((self.bytes_written as f64) * 100.0) / (self.bytes_total as f64);
// Check value
if prog > 100.0 {
prog = 100.0;
} else if prog < 0.0 {
prog = 0.0;
}
self.progress = prog;
}
/// ### byte_per_second
///
/// Calculate bytes per second
pub fn bytes_per_second(&self) -> u64 {
// bytes_written : elapsed_secs = x : 1
let elapsed_secs: u64 = self.started.elapsed().as_secs();
match elapsed_secs {
0 => match self.bytes_written == self.bytes_total {
// NOTE: would divide by 0 :D
true => self.bytes_total as u64, // Download completed in less than 1 second
false => 0, // 0 B/S
},
_ => self.bytes_written as u64 / elapsed_secs,
}
}
}
@@ -251,13 +229,14 @@ pub struct FileTransferActivity {
context: Option<Context>, // Context holder
params: FileTransferParams, // FT connection params
client: Box<dyn FileTransfer>, // File transfer client
config_cli: Option<ConfigClient>, // Config Client
local: FileExplorer, // Local File explorer state
remote: FileExplorer, // Remote File explorer state
tab: FileExplorerTab, // Current selected tab
log_index: usize, // Current log index entry selected
log_records: VecDeque<LogRecord>, // Log records
log_size: usize, // Log records size (max)
input_mode: InputMode, // Current input mode
popup: Option<Popup>, // Current input mode
input_field: InputField, // Current selected input mode
input_txt: String, // Input text
choice_opt: DialogYesNoOption, // Dialog popup selected option
@@ -269,24 +248,31 @@ impl FileTransferActivity {
///
/// Instantiates a new FileTransferActivity
pub fn new(params: FileTransferParams) -> FileTransferActivity {
let protocol: FileTransferProtocol = params.protocol.clone();
let protocol: FileTransferProtocol = params.protocol;
// Get config client
let config_client: Option<ConfigClient> = Self::init_config_client();
FileTransferActivity {
disconnected: false,
quit: false,
context: None,
client: match protocol {
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new()),
FileTransferProtocol::Sftp => Box::new(SftpFileTransfer::new(
Self::make_ssh_storage(config_client.as_ref()),
)),
FileTransferProtocol::Ftp(ftps) => Box::new(FtpFileTransfer::new(ftps)),
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new()),
FileTransferProtocol::Scp => Box::new(ScpFileTransfer::new(
Self::make_ssh_storage(config_client.as_ref()),
)),
},
params,
local: FileExplorer::new(),
remote: FileExplorer::new(),
local: Self::build_explorer(config_client.as_ref()),
remote: Self::build_explorer(config_client.as_ref()),
config_cli: config_client,
tab: FileExplorerTab::Local,
log_index: 0,
log_records: VecDeque::with_capacity(256), // 256 events is enough I guess
log_size: 256, // Must match with capacity
input_mode: InputMode::Explorer,
popup: None,
input_field: InputField::Explorer,
input_txt: String::new(),
choice_opt: DialogYesNoOption::Yes,
@@ -310,11 +296,18 @@ impl Activity for FileTransferActivity {
// Set context
self.context = Some(context);
// Clear terminal
let _ = self.context.as_mut().unwrap().terminal.clear();
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
// Set working directory
let pwd: PathBuf = self.context.as_ref().unwrap().local.pwd();
// Get files at current wd
self.local_scan(self.context.as_ref().unwrap().local.pwd().as_path());
self.local_scan(pwd.as_path());
self.local.wrkdir = pwd;
// Index at first valid
self.local.index_at_first();
// Configure text editor
self.setup_text_editor();
}
/// ### on_draw
@@ -322,16 +315,16 @@ impl Activity for FileTransferActivity {
/// `on_draw` is the function which draws the graphical interface.
/// This function must be called at each tick to refresh the interface
fn on_draw(&mut self) {
let mut redraw: bool = false; // Should ui actually be redrawned?
// Context must be something
// Should ui actually be redrawned?
let mut redraw: bool = false;
// Context must be something
if self.context.is_none() {
return;
}
let is_explorer_mode: bool = matches!(self.input_mode, InputMode::Explorer);
// Check if connected
if !self.client.is_connected() && is_explorer_mode {
// Check if connected (popup must be None, otherwise would try reconnecting in loop in case of error)
if !self.client.is_connected() && self.popup.is_none() {
// Set init state to connecting popup
self.input_mode = InputMode::Popup(PopupType::Wait(format!(
self.popup = Some(Popup::Wait(format!(
"Connecting to {}:{}...",
self.params.address, self.params.port
)));
@@ -364,7 +357,7 @@ impl Activity for FileTransferActivity {
// Clear terminal and return
match self.context.take() {
Some(mut ctx) => {
let _ = ctx.terminal.clear();
ctx.clear_screen();
Some(ctx)
}
None => None,

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -30,6 +30,7 @@ use super::context::Context;
// Activities
pub mod auth_activity;
pub mod filetransfer_activity;
pub mod setup_activity;
// Activity trait

View File

@@ -0,0 +1,152 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::{Color, Popup, SetupActivity};
// Ext
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::env;
impl SetupActivity {
/// ### callback_nothing_to_do
///
/// Self titled
pub(super) fn callback_nothing_to_do(&mut self) {}
/// ### callback_save_config_and_quit
///
/// Save configuration and quit
pub(super) fn callback_save_config_and_quit(&mut self) {
match self.save_config() {
Ok(_) => self.quit = true, // Quit after successful save
Err(err) => self.popup = Some(Popup::Alert(Color::Red, err)), // Show error and don't quit
}
}
/// ### callback_save_config
///
/// Save configuration callback
pub(super) fn callback_save_config(&mut self) {
if let Err(err) = self.save_config() {
self.popup = Some(Popup::Alert(Color::Red, err)); // Show save error
}
}
/// ### callback_reset_config_changes
///
/// Reset config changes callback
pub(super) fn callback_reset_config_changes(&mut self) {
if let Err(err) = self.reset_config_changes() {
self.popup = Some(Popup::Alert(Color::Red, err)); // Show reset error
}
}
/// ### callback_delete_ssh_key
///
/// Callback for performing the delete of a ssh key
pub(super) fn callback_delete_ssh_key(&mut self) {
// Get key
if let Some(config_cli) = self.config_cli.as_mut() {
let key: Option<String> = match config_cli.iter_ssh_keys().nth(self.ssh_key_idx) {
Some(k) => Some(k.clone()),
None => None,
};
if let Some(key) = key {
match config_cli.get_ssh_key(&key) {
Ok(opt) => {
if let Some((host, username, _)) = opt {
if let Err(err) = self.delete_ssh_key(host.as_str(), username.as_str())
{
// Report error
self.popup = Some(Popup::Alert(Color::Red, err));
}
}
}
Err(err) => {
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not get ssh key \"{}\": {}", key, err),
))
} // Report error
}
}
}
}
/// ### callback_new_ssh_key
///
/// Create a new ssh key with provided parameters
pub(super) fn callback_new_ssh_key(&mut self, host: String, username: String) {
if let Some(cli) = self.config_cli.as_ref() {
// Prepare text editor
env::set_var("EDITOR", cli.get_text_editor());
let placeholder: String = format!("# Type private SSH key for {}@{}\n", username, host);
// Put input mode back to normal
let _ = disable_raw_mode();
// Leave alternate mode
if let Some(ctx) = self.context.as_mut() {
ctx.leave_alternate_screen();
}
// Re-enable raw mode
let _ = enable_raw_mode();
// Write key to file
match edit::edit(placeholder.as_bytes()) {
Ok(rsa_key) => {
// Remove placeholder from `rsa_key`
let rsa_key: String = rsa_key.as_str().replace(placeholder.as_str(), "");
if rsa_key.is_empty() {
// Report error: empty key
self.popup = Some(Popup::Alert(Color::Red, "SSH Key is empty".to_string()));
} else {
// Add key
if let Err(err) =
self.add_ssh_key(host.as_str(), username.as_str(), rsa_key.as_str())
{
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not create new private key: {}", err),
))
}
}
}
Err(err) => {
// Report error
self.popup = Some(Popup::Alert(
Color::Red,
format!("Could not write private key to file: {}", err),
))
}
}
// Restore terminal
if let Some(ctx) = self.context.as_mut() {
// Clear screen
ctx.clear_screen();
// Enter alternate mode
ctx.enter_alternate_screen();
}
}
}
}

View File

@@ -0,0 +1,194 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::{ConfigClient, Popup, SetupActivity};
use crate::system::environment;
// Ext
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use std::env;
use std::path::PathBuf;
impl SetupActivity {
/// ### init_config_dir
///
/// Initialize configuration directory
pub(super) fn init_config_client(&mut self) {
match environment::init_config_dir() {
Ok(config_dir) => match config_dir {
Some(config_dir) => {
// Get paths
let (config_file, ssh_dir): (PathBuf, PathBuf) =
environment::get_config_paths(config_dir.as_path());
// Create config client
match ConfigClient::new(config_file.as_path(), ssh_dir.as_path()) {
Ok(cli) => self.config_cli = Some(cli),
Err(err) => {
self.popup = Some(Popup::Fatal(format!(
"Could not initialize configuration client: {}",
err
)))
}
}
}
None => {
self.popup = Some(Popup::Fatal(
"No configuration directory is available on your system".to_string(),
))
}
},
Err(err) => {
self.popup = Some(Popup::Fatal(format!(
"Could not initialize configuration directory: {}",
err
)))
}
}
}
/// ### save_config
///
/// Save configuration
pub(super) fn save_config(&mut self) -> Result<(), String> {
match &self.config_cli {
Some(cli) => match cli.write_config() {
Ok(_) => Ok(()),
Err(err) => Err(format!("Could not save configuration: {}", err)),
},
None => Ok(()),
}
}
/// ### reset_config_changes
///
/// Reset configuration changes; pratically read config from file, overwriting any change made
/// since last write action
pub(super) fn reset_config_changes(&mut self) -> Result<(), String> {
match self.config_cli.as_mut() {
Some(cli) => match cli.read_config() {
Ok(_) => Ok(()),
Err(err) => Err(format!("Could not restore configuration: {}", err)),
},
None => Ok(()),
}
}
/// ### delete_ssh_key
///
/// Delete ssh key from config cli
pub(super) fn delete_ssh_key(&mut self, host: &str, username: &str) -> Result<(), String> {
match self.config_cli.as_mut() {
Some(cli) => match cli.del_ssh_key(host, username) {
Ok(_) => Ok(()),
Err(err) => Err(format!(
"Could not delete ssh key \"{}@{}\": {}",
host, username, err
)),
},
None => Ok(()),
}
}
/// ### edit_ssh_key
///
/// Edit selected ssh key
pub(super) fn edit_ssh_key(&mut self) -> Result<(), String> {
match self.config_cli.as_ref() {
Some(cli) => {
// Set text editor
env::set_var("EDITOR", cli.get_text_editor());
// Prepare terminal
let _ = disable_raw_mode();
// Leave alternate mode
if let Some(ctx) = self.context.as_mut() {
ctx.leave_alternate_screen();
}
// Check if key exists
match cli.iter_ssh_keys().nth(self.ssh_key_idx) {
Some(key) => {
// Get key path
match cli.get_ssh_key(key) {
Ok(ssh_key) => match ssh_key {
None => Ok(()),
Some((_, _, key_path)) => match edit::edit_file(key_path.as_path())
{
Ok(_) => {
// Restore terminal
if let Some(ctx) = self.context.as_mut() {
// Clear screen
ctx.clear_screen();
// Enter alternate mode
ctx.enter_alternate_screen();
}
// Re-enable raw mode
let _ = enable_raw_mode();
Ok(())
}
Err(err) => {
// Restore terminal
if let Some(ctx) = self.context.as_mut() {
// Clear screen
ctx.clear_screen();
// Enter alternate mode
ctx.enter_alternate_screen();
}
// Re-enable raw mode
let _ = enable_raw_mode();
Err(format!("Could not edit ssh key: {}", err))
}
},
},
Err(err) => Err(format!("Could not read ssh key: {}", err)),
}
}
None => Ok(()),
}
}
None => Ok(()),
}
}
/// ### add_ssh_key
///
/// Add provided ssh key to config client
pub(super) fn add_ssh_key(
&mut self,
host: &str,
username: &str,
rsa_key: &str,
) -> Result<(), String> {
match self.config_cli.as_mut() {
Some(cli) => {
// Add key to client
match cli.add_ssh_key(host, username, rsa_key) {
Ok(_) => Ok(()),
Err(err) => Err(format!("Could not add SSH key: {}", err)),
}
}
None => Ok(()),
}
}
}

View File

@@ -0,0 +1,565 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Locals
use super::{
InputEvent, OnChoiceCallback, Popup, QuitDialogOption, SetupActivity, SetupTab,
UserInterfaceInputField, YesNoDialogOption,
};
use crate::filetransfer::FileTransferProtocol;
use crate::fs::explorer::GroupDirs;
// Ext
use crossterm::event::{KeyCode, KeyModifiers};
use std::path::PathBuf;
use tui::style::Color;
impl SetupActivity {
/// ### handle_input_event
///
/// Handle input event, based on current input mode
pub(super) fn handle_input_event(&mut self, ev: &InputEvent) {
let popup: Option<Popup> = match &self.popup {
Some(ptype) => Some(ptype.clone()),
None => None,
};
match &self.popup {
Some(_) => self.handle_input_event_popup(ev, popup.unwrap()),
None => self.handle_input_event_forms(ev),
}
}
/// ### handle_input_event_forms
///
/// Handle input event when popup is not visible.
/// InputEvent is handled based on current tab
fn handle_input_event_forms(&mut self, ev: &InputEvent) {
// Match tab
match &self.tab {
SetupTab::SshConfig => self.handle_input_event_forms_ssh_config(ev),
SetupTab::UserInterface(_) => self.handle_input_event_forms_ui(ev),
}
}
/// ### handle_input_event_forms_ssh_config
///
/// Handle input event when in ssh config tab
fn handle_input_event_forms_ssh_config(&mut self, ev: &InputEvent) {
// Match input event
if let InputEvent::Key(key) = ev {
// Match key code
match key.code {
KeyCode::Esc => self.popup = Some(Popup::Quit), // Prompt quit
KeyCode::Tab => {
self.tab = SetupTab::UserInterface(UserInterfaceInputField::DefaultProtocol)
} // Switch tab to user interface config
KeyCode::Up => {
if let Some(config_cli) = self.config_cli.as_ref() {
// Move ssh key index up
let ssh_key_size: usize = config_cli.iter_ssh_keys().count();
if self.ssh_key_idx > 0 {
// Decrement
self.ssh_key_idx -= 1;
} else {
// Set ssh key index to `ssh_key_size -1`
self.ssh_key_idx = ssh_key_size - 1;
}
}
}
KeyCode::Down => {
if let Some(config_cli) = self.config_cli.as_ref() {
// Move ssh key index down
let ssh_key_size: usize = config_cli.iter_ssh_keys().count();
if self.ssh_key_idx + 1 < ssh_key_size {
// Increment index
self.ssh_key_idx += 1;
} else {
// Wrap to 0
self.ssh_key_idx = 0;
}
}
}
KeyCode::Delete => {
// Prompt to delete selected key
self.yesno_opt = YesNoDialogOption::No; // Default to no
self.popup = Some(Popup::YesNo(
String::from("Delete key?"),
Self::callback_delete_ssh_key,
Self::callback_nothing_to_do,
));
}
KeyCode::Enter => {
// Edit selected key
if let Err(err) = self.edit_ssh_key() {
self.popup = Some(Popup::Alert(Color::Red, err)); // Report error
}
}
KeyCode::Char(ch) => {
// Check if <CTRL> is enabled
if key.modifiers.intersects(KeyModifiers::CONTROL) {
// Match char
match ch {
'e' | 'E' => {
// Prompt to delete selected key
self.yesno_opt = YesNoDialogOption::No; // Default to no
self.popup = Some(Popup::YesNo(
String::from("Delete key?"),
Self::callback_delete_ssh_key,
Self::callback_nothing_to_do,
));
}
'h' | 'H' => {
// Show help
self.popup = Some(Popup::Help);
}
'n' | 'N' => {
// New ssh key
self.popup = Some(Popup::NewSshKey);
}
'r' | 'R' => {
// Show reset changes dialog
self.popup = Some(Popup::YesNo(
String::from("Reset changes?"),
Self::callback_reset_config_changes,
Self::callback_nothing_to_do,
));
}
's' | 'S' => {
// Show save dialog
self.popup = Some(Popup::YesNo(
String::from("Save changes to configuration?"),
Self::callback_save_config,
Self::callback_nothing_to_do,
));
}
_ => { /* Nothing to do */ }
}
}
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_forms_ui
///
/// Handle input event when in UserInterface config tab
fn handle_input_event_forms_ui(&mut self, ev: &InputEvent) {
// Get `UserInterfaceInputField`
let field: UserInterfaceInputField = match &self.tab {
SetupTab::UserInterface(field) => field.clone(),
_ => return,
};
// Match input event
if let InputEvent::Key(key) = ev {
// Match key code
match key.code {
KeyCode::Esc => self.popup = Some(Popup::Quit), // Prompt quit
KeyCode::Tab => self.tab = SetupTab::SshConfig, // Switch tab to ssh config
KeyCode::Backspace => {
// Pop character from selected input
if let Some(config_cli) = self.config_cli.as_mut() {
match field {
UserInterfaceInputField::TextEditor => {
// Pop from text editor
let mut input: String = String::from(
config_cli.get_text_editor().as_path().to_string_lossy(),
);
input.pop();
// Update text editor value
config_cli.set_text_editor(PathBuf::from(input.as_str()));
}
UserInterfaceInputField::FileFmt => {
// Push char to current file fmt
let mut file_fmt = config_cli.get_file_fmt().unwrap_or_default();
// Pop from file fmt
file_fmt.pop();
// If len is 0, will become None
config_cli.set_file_fmt(file_fmt);
}
_ => { /* Not a text field */ }
}
// NOTE: replace with match if other text fields are added
if matches!(field, UserInterfaceInputField::TextEditor) {}
}
}
KeyCode::Left => {
// Move left on fields which are tabs
if let Some(config_cli) = self.config_cli.as_mut() {
match field {
UserInterfaceInputField::DefaultProtocol => {
// Move left
config_cli.set_default_protocol(
match config_cli.get_default_protocol() {
FileTransferProtocol::Ftp(secure) => match secure {
true => FileTransferProtocol::Ftp(false),
false => FileTransferProtocol::Scp,
},
FileTransferProtocol::Scp => FileTransferProtocol::Sftp,
FileTransferProtocol::Sftp => {
FileTransferProtocol::Ftp(true)
} // Wrap
},
);
}
UserInterfaceInputField::GroupDirs => {
// Move left
config_cli.set_group_dirs(match config_cli.get_group_dirs() {
None => Some(GroupDirs::Last),
Some(val) => match val {
GroupDirs::Last => Some(GroupDirs::First),
GroupDirs::First => None,
},
});
}
UserInterfaceInputField::ShowHiddenFiles => {
// Move left
config_cli.set_show_hidden_files(true);
}
UserInterfaceInputField::CheckForUpdates => {
// move left
config_cli.set_check_for_updates(true);
}
_ => { /* Not a tab field */ }
}
}
}
KeyCode::Right => {
// Move right on fields which are tabs
if let Some(config_cli) = self.config_cli.as_mut() {
match field {
UserInterfaceInputField::DefaultProtocol => {
// Move left
config_cli.set_default_protocol(
match config_cli.get_default_protocol() {
FileTransferProtocol::Sftp => FileTransferProtocol::Scp,
FileTransferProtocol::Scp => {
FileTransferProtocol::Ftp(false)
}
FileTransferProtocol::Ftp(secure) => match secure {
false => FileTransferProtocol::Ftp(true),
true => FileTransferProtocol::Sftp, // Wrap
},
},
);
}
UserInterfaceInputField::GroupDirs => {
// Move right
config_cli.set_group_dirs(match config_cli.get_group_dirs() {
Some(val) => match val {
GroupDirs::First => Some(GroupDirs::Last),
GroupDirs::Last => None,
},
None => Some(GroupDirs::First),
});
}
UserInterfaceInputField::ShowHiddenFiles => {
// Move right
config_cli.set_show_hidden_files(false);
}
UserInterfaceInputField::CheckForUpdates => {
// move right
config_cli.set_check_for_updates(false);
}
_ => { /* Not a tab field */ }
}
}
}
KeyCode::Up => {
// Change selected field
self.tab = SetupTab::UserInterface(match field {
UserInterfaceInputField::FileFmt => UserInterfaceInputField::GroupDirs,
UserInterfaceInputField::GroupDirs => {
UserInterfaceInputField::CheckForUpdates
}
UserInterfaceInputField::CheckForUpdates => {
UserInterfaceInputField::ShowHiddenFiles
}
UserInterfaceInputField::ShowHiddenFiles => {
UserInterfaceInputField::DefaultProtocol
}
UserInterfaceInputField::DefaultProtocol => {
UserInterfaceInputField::TextEditor
}
UserInterfaceInputField::TextEditor => UserInterfaceInputField::FileFmt, // Wrap
});
}
KeyCode::Down => {
// Change selected field
self.tab = SetupTab::UserInterface(match field {
UserInterfaceInputField::TextEditor => {
UserInterfaceInputField::DefaultProtocol
}
UserInterfaceInputField::DefaultProtocol => {
UserInterfaceInputField::ShowHiddenFiles
}
UserInterfaceInputField::ShowHiddenFiles => {
UserInterfaceInputField::CheckForUpdates
}
UserInterfaceInputField::CheckForUpdates => {
UserInterfaceInputField::GroupDirs
}
UserInterfaceInputField::GroupDirs => UserInterfaceInputField::FileFmt,
UserInterfaceInputField::FileFmt => UserInterfaceInputField::TextEditor, // Wrap
});
}
KeyCode::Char(ch) => {
// Check if <CTRL> is enabled
if key.modifiers.intersects(KeyModifiers::CONTROL) {
// Match char
match ch {
'h' | 'H' => {
// Show help
self.popup = Some(Popup::Help);
}
'r' | 'R' => {
// Show reset changes dialog
self.popup = Some(Popup::YesNo(
String::from("Reset changes?"),
Self::callback_reset_config_changes,
Self::callback_nothing_to_do,
));
}
's' | 'S' => {
// Show save dialog
self.popup = Some(Popup::YesNo(
String::from("Save changes to configuration?"),
Self::callback_save_config,
Self::callback_nothing_to_do,
));
}
_ => { /* Nothing to do */ }
}
} else {
// Push character to input field
if let Some(config_cli) = self.config_cli.as_mut() {
// NOTE: change to match if other fields are added
match field {
UserInterfaceInputField::TextEditor => {
// Get current text editor and push character
let mut input: String = String::from(
config_cli.get_text_editor().as_path().to_string_lossy(),
);
input.push(ch);
// Update text editor value
config_cli.set_text_editor(PathBuf::from(input.as_str()));
}
UserInterfaceInputField::FileFmt => {
// Push char to current file fmt
let mut file_fmt =
config_cli.get_file_fmt().unwrap_or_default();
file_fmt.push(ch);
// update value
config_cli.set_file_fmt(file_fmt);
}
_ => { /* Not a text field */ }
}
}
}
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_popup
///
/// Handler for input event when popup is visible
fn handle_input_event_popup(&mut self, ev: &InputEvent, ptype: Popup) {
match ptype {
Popup::Alert(_, _) => self.handle_input_event_mode_popup_alert(ev),
Popup::Fatal(_) => self.handle_input_event_mode_popup_fatal(ev),
Popup::Help => self.handle_input_event_mode_popup_help(ev),
Popup::NewSshKey => self.handle_input_event_mode_popup_newsshkey(ev),
Popup::Quit => self.handle_input_event_mode_popup_quit(ev),
Popup::YesNo(_, yes_cb, no_cb) => {
self.handle_input_event_mode_popup_yesno(ev, yes_cb, no_cb)
}
}
}
/// ### handle_input_event_mode_popup_alert
///
/// Handle input event when the input mode is popup, and popup type is alert
fn handle_input_event_mode_popup_alert(&mut self, ev: &InputEvent) {
// Only enter should be allowed here
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
self.popup = None; // Hide popup
}
}
}
/// ### handle_input_event_mode_popup_fatal
///
/// Handle input event when the input mode is popup, and popup type is fatal
fn handle_input_event_mode_popup_fatal(&mut self, ev: &InputEvent) {
// Only enter should be allowed here
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
// Quit after acknowelding fatal error
self.quit = true;
}
}
}
/// ### handle_input_event_mode_popup_help
///
/// Input event handler for popup help
fn handle_input_event_mode_popup_help(&mut self, ev: &InputEvent) {
// If enter, close popup
if let InputEvent::Key(key) = ev {
if matches!(key.code, KeyCode::Esc | KeyCode::Enter) {
self.popup = None; // Hide popup
}
}
}
/// ### handle_input_event_mode_popup_newsshkey
///
/// Handle input events for `Popup::NewSshKey`
fn handle_input_event_mode_popup_newsshkey(&mut self, ev: &InputEvent) {
// If enter, close popup, otherwise push chars to input
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Abort input
// Clear buffer
self.clear_user_input();
// Hide popup
self.popup = None;
}
KeyCode::Enter => {
// Submit
let address: String = self.user_input.get(0).unwrap().to_string();
let username: String = self.user_input.get(1).unwrap().to_string();
// Clear buffer
self.clear_user_input();
// Close popup BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.popup = None;
// Reset user ptr
self.user_input_ptr = 0;
// Call cb
self.callback_new_ssh_key(address, username);
}
KeyCode::Up => {
// Move ptr up, or to maximum index (1)
self.user_input_ptr = match self.user_input_ptr {
1 => 0,
_ => 1, // Wrap
};
}
KeyCode::Down => {
// Move ptr down, or to minimum index (0)
self.user_input_ptr = match self.user_input_ptr {
0 => 1,
_ => 0, // Wrap
}
}
KeyCode::Char(ch) => {
// Get current input
let input: &mut String = self.user_input.get_mut(self.user_input_ptr).unwrap();
input.push(ch);
}
KeyCode::Backspace => {
let input: &mut String = self.user_input.get_mut(self.user_input_ptr).unwrap();
input.pop();
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_popup_quit
///
/// Handle input events for `Popup::Quit`
fn handle_input_event_mode_popup_quit(&mut self, ev: &InputEvent) {
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Esc => {
// Hide popup
self.popup = None;
}
KeyCode::Enter => {
// Perform enter, based on current choice
match self.quit_opt {
QuitDialogOption::Cancel => self.popup = None, // Hide popup
QuitDialogOption::DontSave => self.quit = true, // Just quit
QuitDialogOption::Save => self.callback_save_config_and_quit(), // Save and quit
}
// Reset choice
self.quit_opt = QuitDialogOption::Save;
}
KeyCode::Right => {
// Change option
self.quit_opt = match self.quit_opt {
QuitDialogOption::Save => QuitDialogOption::DontSave,
QuitDialogOption::DontSave => QuitDialogOption::Cancel,
QuitDialogOption::Cancel => QuitDialogOption::Save, // Wrap
}
}
KeyCode::Left => {
// Change option
self.quit_opt = match self.quit_opt {
QuitDialogOption::Cancel => QuitDialogOption::DontSave,
QuitDialogOption::DontSave => QuitDialogOption::Save,
QuitDialogOption::Save => QuitDialogOption::Cancel, // Wrap
}
}
_ => { /* Nothing to do */ }
}
}
}
/// ### handle_input_event_mode_popup_yesno
///
/// Input event handler for popup alert
fn handle_input_event_mode_popup_yesno(
&mut self,
ev: &InputEvent,
yes_cb: OnChoiceCallback,
no_cb: OnChoiceCallback,
) {
// If enter, close popup, otherwise move dialog option
if let InputEvent::Key(key) = ev {
match key.code {
KeyCode::Enter => {
// Hide popup BEFORE CALLBACKS!!! Callback can then overwrite this, clever uh?
self.popup = None;
// Check if user selected yes or not
match self.yesno_opt {
YesNoDialogOption::No => no_cb(self),
YesNoDialogOption::Yes => yes_cb(self),
}
// Reset choice option to yes
self.yesno_opt = YesNoDialogOption::Yes;
}
KeyCode::Right => self.yesno_opt = YesNoDialogOption::No, // Set to NO
KeyCode::Left => self.yesno_opt = YesNoDialogOption::Yes, // Set to YES
_ => { /* Nothing to do */ }
}
}
}
}

View File

@@ -0,0 +1,786 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::{
Context, Popup, QuitDialogOption, SetupActivity, SetupTab, UserInterfaceInputField,
YesNoDialogOption,
};
use crate::filetransfer::FileTransferProtocol;
use crate::fs::explorer::GroupDirs;
use crate::utils::fmt::align_text_center;
// Ext
use tui::{
layout::{Constraint, Corner, Direction, Layout, Rect},
style::{Color, Modifier, Style},
text::{Span, Spans, Text},
widgets::{Block, BorderType, Borders, Clear, List, ListItem, ListState, Paragraph, Tabs},
};
use unicode_width::UnicodeWidthStr;
impl SetupActivity {
/// ### draw
///
/// Draw UI
pub(super) fn draw(&mut self) {
let mut ctx: Context = self.context.take().unwrap();
let _ = ctx.terminal.draw(|f| {
// Prepare main chunks
let chunks = Layout::default()
.direction(Direction::Vertical)
.margin(1)
.constraints(
[
Constraint::Length(3), // Current tab
Constraint::Percentage(90), // Main body
Constraint::Length(3), // Help footer
]
.as_ref(),
)
.split(f.size());
// Prepare selected tab
f.render_widget(self.draw_selected_tab(), chunks[0]);
// Draw main layout
match &self.tab {
SetupTab::SshConfig => {
// Draw ssh config
// Create explorer chunks
let sshcfg_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints([Constraint::Percentage(100)].as_ref())
.split(chunks[1]);
if let Some(ssh_key_tab) = self.draw_ssh_keys_list() {
// Create ssh list state
let mut ssh_key_state: ListState = ListState::default();
ssh_key_state.select(Some(self.ssh_key_idx));
// Render ssh keys
f.render_stateful_widget(ssh_key_tab, sshcfg_chunks[0], &mut ssh_key_state);
}
}
SetupTab::UserInterface(form_field) => {
// Create chunks
let ui_cfg_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(3),
Constraint::Length(1),
]
.as_ref(),
)
.split(chunks[1]);
// Render input forms
if let Some(field) = self.draw_text_editor_input() {
f.render_widget(field, ui_cfg_chunks[0]);
}
if let Some(tab) = self.draw_default_protocol_tab() {
f.render_widget(tab, ui_cfg_chunks[1]);
}
if let Some(tab) = self.draw_hidden_files_tab() {
f.render_widget(tab, ui_cfg_chunks[2]);
}
if let Some(tab) = self.draw_check_for_updates_tab() {
f.render_widget(tab, ui_cfg_chunks[3]);
}
if let Some(tab) = self.draw_default_group_dirs_tab() {
f.render_widget(tab, ui_cfg_chunks[4]);
}
if let Some(tab) = self.draw_file_fmt_input() {
f.render_widget(tab, ui_cfg_chunks[5]);
}
// Set cursor
if let Some(cli) = &self.config_cli {
match form_field {
UserInterfaceInputField::TextEditor => {
let editor_text: String =
String::from(cli.get_text_editor().as_path().to_string_lossy());
f.set_cursor(
ui_cfg_chunks[0].x + editor_text.width() as u16 + 1,
ui_cfg_chunks[0].y + 1,
);
}
UserInterfaceInputField::FileFmt => {
let file_fmt: String = cli.get_file_fmt().unwrap_or_default();
f.set_cursor(
ui_cfg_chunks[4].x + file_fmt.width() as u16 + 1,
ui_cfg_chunks[4].y + 1,
);
}
_ => { /* Not a text field */ }
}
}
}
}
// Draw footer
f.render_widget(self.draw_footer(), chunks[2]);
// Draw popup
if let Some(popup) = &self.popup {
// Calculate popup size
let (width, height): (u16, u16) = match popup {
Popup::Alert(_, _) | Popup::Fatal(_) => (50, 10),
Popup::Help => (50, 70),
Popup::NewSshKey => (50, 20),
Popup::Quit => (40, 10),
Popup::YesNo(_, _, _) => (30, 10),
};
let popup_area: Rect = self.draw_popup_area(f.size(), width, height);
f.render_widget(Clear, popup_area); //this clears out the background
match popup {
Popup::Alert(color, txt) => f.render_widget(
self.draw_popup_alert(*color, txt.clone(), popup_area.width),
popup_area,
),
Popup::Fatal(txt) => f.render_widget(
self.draw_popup_fatal(txt.clone(), popup_area.width),
popup_area,
),
Popup::Help => f.render_widget(self.draw_popup_help(), popup_area),
Popup::NewSshKey => {
let popup_chunks = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Length(3), // Address form
Constraint::Length(3), // Username form
]
.as_ref(),
)
.split(popup_area);
let (address_form, username_form): (Paragraph, Paragraph) =
self.draw_popup_new_ssh_key();
// Render parts
f.render_widget(address_form, popup_chunks[0]);
f.render_widget(username_form, popup_chunks[1]);
// Set cursor to popup form
if self.user_input_ptr < 2 {
if let Some(selected_text) = self.user_input.get(self.user_input_ptr) {
// Set cursor
f.set_cursor(
popup_chunks[self.user_input_ptr].x
+ selected_text.width() as u16
+ 1,
popup_chunks[self.user_input_ptr].y + 1,
)
}
}
}
Popup::Quit => f.render_widget(self.draw_popup_quit(), popup_area),
Popup::YesNo(txt, _, _) => {
f.render_widget(self.draw_popup_yesno(txt.clone()), popup_area)
}
}
}
});
self.context = Some(ctx);
}
/// ### draw_selecte_tab
///
/// Draw selected tab tab
fn draw_selected_tab(&self) -> Tabs {
let choices: Vec<Spans> = vec![Spans::from("User Interface"), Spans::from("SSH Keys")];
let index: usize = match self.tab {
SetupTab::UserInterface(_) => 0,
SetupTab::SshConfig => 1,
};
Tabs::new(choices)
.block(Block::default().borders(Borders::BOTTOM).title("Setup"))
.select(index)
.style(Style::default())
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
)
}
/// ### draw_footer
///
/// Draw authentication page footer
fn draw_footer(&self) -> Paragraph {
// Write header
let (footer, h_style) = (
vec![
Span::raw("Press "),
Span::styled(
"<CTRL+H>",
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Cyan),
),
Span::raw(" to show keybindings"),
],
Style::default().add_modifier(Modifier::BOLD),
);
let mut footer_text = Text::from(Spans::from(footer));
footer_text.patch_style(h_style);
Paragraph::new(footer_text)
}
/// ### draw_text_editor_input
///
/// Draw input text field for text editor parameter
fn draw_text_editor_input(&self) -> Option<Paragraph> {
match &self.config_cli {
Some(cli) => Some(
Paragraph::new(String::from(
cli.get_text_editor().as_path().to_string_lossy(),
))
.style(Style::default().fg(match &self.tab {
SetupTab::SshConfig => Color::White,
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::TextEditor => Color::LightGreen,
_ => Color::White,
},
}))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Text Editor"),
),
),
None => None,
}
}
/// ### draw_default_protocol_tab
///
/// Draw default protocol input tab
fn draw_default_protocol_tab(&self) -> Option<Tabs> {
// Check if config client is some
match &self.config_cli {
Some(cli) => {
let choices: Vec<Spans> = vec![
Spans::from("SFTP"),
Spans::from("SCP"),
Spans::from("FTP"),
Spans::from("FTPS"),
];
let index: usize = match cli.get_default_protocol() {
FileTransferProtocol::Sftp => 0,
FileTransferProtocol::Scp => 1,
FileTransferProtocol::Ftp(secure) => match secure {
false => 2,
true => 3,
},
};
let (bg, fg, block_fg): (Color, Color, Color) = match &self.tab {
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::DefaultProtocol => {
(Color::Cyan, Color::Black, Color::Cyan)
}
_ => (Color::Reset, Color::Cyan, Color::Reset),
},
_ => (Color::Reset, Color::Reset, Color::Reset),
};
Some(
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(block_fg))
.title("Default File Transfer Protocol"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default().add_modifier(Modifier::BOLD).fg(fg).bg(bg),
),
)
}
None => None,
}
}
/// ### draw_hidden_files_tab
///
/// Draw default hidden files tab
fn draw_hidden_files_tab(&self) -> Option<Tabs> {
// Check if config client is some
match &self.config_cli {
Some(cli) => {
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
let index: usize = match cli.get_show_hidden_files() {
true => 0,
false => 1,
};
let (bg, fg, block_fg): (Color, Color, Color) = match &self.tab {
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::ShowHiddenFiles => {
(Color::LightRed, Color::Black, Color::LightRed)
}
_ => (Color::Reset, Color::LightRed, Color::Reset),
},
_ => (Color::Reset, Color::Reset, Color::Reset),
};
Some(
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(block_fg))
.title("Show hidden files (by default)"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default().add_modifier(Modifier::BOLD).fg(fg).bg(bg),
),
)
}
None => None,
}
}
/// ### draw_check_for_updates_tab
///
/// Draw check for updates tab
fn draw_check_for_updates_tab(&self) -> Option<Tabs> {
// Check if config client is some
match &self.config_cli {
Some(cli) => {
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
let index: usize = match cli.get_check_for_updates() {
true => 0,
false => 1,
};
let (bg, fg, block_fg): (Color, Color, Color) = match &self.tab {
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::CheckForUpdates => {
(Color::LightYellow, Color::Black, Color::LightYellow)
}
_ => (Color::Reset, Color::LightYellow, Color::Reset),
},
_ => (Color::Reset, Color::Reset, Color::Reset),
};
Some(
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(block_fg))
.title("Check for updates?"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default().add_modifier(Modifier::BOLD).fg(fg).bg(bg),
),
)
}
None => None,
}
}
/// ### draw_default_group_dirs_tab
///
/// Draw group dirs input tab
fn draw_default_group_dirs_tab(&self) -> Option<Tabs> {
// Check if config client is some
match &self.config_cli {
Some(cli) => {
let choices: Vec<Spans> = vec![
Spans::from("Display First"),
Spans::from("Display Last"),
Spans::from("No"),
];
let index: usize = match cli.get_group_dirs() {
None => 2,
Some(val) => match val {
GroupDirs::First => 0,
GroupDirs::Last => 1,
},
};
let (bg, fg, block_fg): (Color, Color, Color) = match &self.tab {
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::GroupDirs => {
(Color::LightMagenta, Color::Black, Color::LightMagenta)
}
_ => (Color::Reset, Color::LightMagenta, Color::Reset),
},
_ => (Color::Reset, Color::Reset, Color::Reset),
};
Some(
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.style(Style::default().fg(block_fg))
.title("Group directories"),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default().add_modifier(Modifier::BOLD).fg(fg).bg(bg),
),
)
}
None => None,
}
}
/// ### draw_file_fmt_input
///
/// Draw input text field for file fmt
fn draw_file_fmt_input(&self) -> Option<Paragraph> {
match &self.config_cli {
Some(cli) => Some(
Paragraph::new(cli.get_file_fmt().unwrap_or_default())
.style(Style::default().fg(match &self.tab {
SetupTab::SshConfig => Color::White,
SetupTab::UserInterface(field) => match field {
UserInterfaceInputField::FileFmt => Color::LightCyan,
_ => Color::White,
},
}))
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("File formatter syntax"),
),
),
None => None,
}
}
/// ### draw_ssh_keys_list
///
/// Draw ssh keys list
fn draw_ssh_keys_list(&self) -> Option<List> {
// Check if config client is some
match &self.config_cli {
Some(cli) => {
// Iterate over ssh keys
let mut ssh_keys: Vec<ListItem> = Vec::with_capacity(cli.iter_ssh_keys().count());
for key in cli.iter_ssh_keys() {
if let Ok(Some((addr, username, _))) = cli.get_ssh_key(key) {
ssh_keys.push(ListItem::new(Span::from(format!(
"{} at {}",
username, addr,
))));
} else {
continue;
}
}
// Return list
Some(
List::new(ssh_keys)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(Color::LightGreen))
.title("SSH Keys"),
)
.start_corner(Corner::TopLeft)
.highlight_style(
Style::default()
.fg(Color::Black)
.bg(Color::LightGreen)
.add_modifier(Modifier::BOLD),
),
)
}
None => None,
}
}
/// ### draw_popup_area
///
/// Draw popup area
fn draw_popup_area(&self, area: Rect, width: u16, height: u16) -> Rect {
let popup_layout = Layout::default()
.direction(Direction::Vertical)
.constraints(
[
Constraint::Percentage((100 - height) / 2),
Constraint::Percentage(height),
Constraint::Percentage((100 - height) / 2),
]
.as_ref(),
)
.split(area);
Layout::default()
.direction(Direction::Horizontal)
.constraints(
[
Constraint::Percentage((100 - width) / 2),
Constraint::Percentage(width),
Constraint::Percentage((100 - width) / 2),
]
.as_ref(),
)
.split(popup_layout[1])[1]
}
/// ### draw_popup_alert
///
/// Draw alert popup
fn draw_popup_alert(&self, color: Color, text: String, width: u16) -> List {
// Wraps texts
let message_rows = textwrap::wrap(text.as_str(), width as usize);
let mut lines: Vec<ListItem> = Vec::new();
for msg in message_rows.iter() {
lines.push(ListItem::new(Spans::from(align_text_center(msg, width))));
}
List::new(lines)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default().fg(color))
.border_type(BorderType::Rounded)
.title("Alert"),
)
.start_corner(Corner::TopLeft)
.style(Style::default().fg(color))
}
/// ### draw_popup_fatal
///
/// Draw fatal error popup
fn draw_popup_fatal(&self, text: String, width: u16) -> List {
self.draw_popup_alert(Color::Red, text, width)
}
/// ### draw_popup_new_ssh_key
///
/// Draw new ssh key form popup
fn draw_popup_new_ssh_key(&self) -> (Paragraph, Paragraph) {
let address: Paragraph = Paragraph::new(self.user_input.get(0).unwrap().as_str())
.style(Style::default().fg(match self.user_input_ptr {
0 => Color::LightCyan,
_ => Color::White,
}))
.block(
Block::default()
.borders(Borders::TOP | Borders::RIGHT | Borders::LEFT)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::White))
.title("Host name or address"),
);
let username: Paragraph = Paragraph::new(self.user_input.get(1).unwrap().as_str())
.style(Style::default().fg(match self.user_input_ptr {
1 => Color::LightMagenta,
_ => Color::White,
}))
.block(
Block::default()
.borders(Borders::BOTTOM | Borders::RIGHT | Borders::LEFT)
.border_type(BorderType::Rounded)
.style(Style::default().fg(Color::White))
.title("Username"),
);
(address, username)
}
/// ### draw_popup_quit
///
/// Draw quit select popup
fn draw_popup_quit(&self) -> Tabs {
let choices: Vec<Spans> = vec![
Spans::from("Save"),
Spans::from("Don't save"),
Spans::from("Cancel"),
];
let index: usize = match self.quit_opt {
QuitDialogOption::Save => 0,
QuitDialogOption::DontSave => 1,
QuitDialogOption::Cancel => 2,
};
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title("Exit setup?"),
)
.select(index)
.style(Style::default())
.highlight_style(Style::default().add_modifier(Modifier::BOLD).fg(Color::Red))
}
/// ### draw_popup_yesno
///
/// Draw yes/no select popup
fn draw_popup_yesno(&self, text: String) -> Tabs {
let choices: Vec<Spans> = vec![Spans::from("Yes"), Spans::from("No")];
let index: usize = match self.yesno_opt {
YesNoDialogOption::Yes => 0,
YesNoDialogOption::No => 1,
};
Tabs::new(choices)
.block(
Block::default()
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.title(text),
)
.select(index)
.style(Style::default())
.highlight_style(
Style::default()
.add_modifier(Modifier::BOLD)
.fg(Color::Yellow),
)
}
/// ### draw_popup_help
///
/// Draw authentication page help popup
fn draw_popup_help(&self) -> List {
// Write header
let cmds: Vec<ListItem> = vec![
ListItem::new(Spans::from(vec![
Span::styled(
"<ESC>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Exit setup"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<TAB>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Change setup page"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<RIGHT/LEFT>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Change selected element in tab"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<UP/DOWN>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Change input field"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<ENTER>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Submit / Dismiss popup"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<DEL>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Delete entry"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+E>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Delete entry"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+H>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Show help"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+N>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("New SSH key"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+R>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Revert changes"),
])),
ListItem::new(Spans::from(vec![
Span::styled(
"<CTRL+S>",
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
),
Span::raw(" "),
Span::raw("Save configuration"),
])),
];
List::new(cmds)
.block(
Block::default()
.borders(Borders::ALL)
.border_style(Style::default())
.border_type(BorderType::Rounded)
.title("Help"),
)
.start_corner(Corner::TopLeft)
}
}

View File

@@ -0,0 +1,38 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
use super::SetupActivity;
impl SetupActivity {
/// ### clear_user_input
///
/// Clear user input buffers
pub(super) fn clear_user_input(&mut self) {
for s in self.user_input.iter_mut() {
s.clear();
}
}
}

View File

@@ -0,0 +1,205 @@
//! ## SetupActivity
//!
//! `setup_activity` is the module which implements the Setup activity, which is the activity to
//! work on termscp configuration
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Submodules
mod callbacks;
mod config;
mod input;
mod layout;
mod misc;
// Deps
extern crate crossterm;
extern crate tui;
// Locals
use super::{Activity, Context};
use crate::system::config_client::ConfigClient;
// Ext
use crossterm::event::Event as InputEvent;
use crossterm::terminal::{disable_raw_mode, enable_raw_mode};
use tui::style::Color;
// Types
type OnChoiceCallback = fn(&mut SetupActivity);
/// ### UserInterfaceInputField
///
/// Input field selected in user interface
#[derive(std::cmp::PartialEq, Clone)]
enum UserInterfaceInputField {
DefaultProtocol,
TextEditor,
ShowHiddenFiles,
CheckForUpdates,
GroupDirs,
FileFmt,
}
/// ### SetupTab
///
/// Selected setup tab
#[derive(std::cmp::PartialEq)]
enum SetupTab {
UserInterface(UserInterfaceInputField),
SshConfig,
}
/// ### QuitDialogOption
///
/// Quit dialog options
#[derive(std::cmp::PartialEq, Clone)]
enum QuitDialogOption {
Save,
DontSave,
Cancel,
}
/// ### YesNoDialogOption
///
/// YesNo dialog options
#[derive(std::cmp::PartialEq, Clone)]
enum YesNoDialogOption {
Yes,
No,
}
/// ## Popup
///
/// Popup describes the type of popup
#[derive(Clone)]
enum Popup {
Alert(Color, String), // Block color; Block text
Fatal(String), // Must quit after being hidden
Help, // Show Help
NewSshKey, //
Quit, // Quit dialog
YesNo(String, OnChoiceCallback, OnChoiceCallback), // Yes/No Dialog
}
/// ## SetupActivity
///
/// Setup activity states holder
pub struct SetupActivity {
pub quit: bool, // Becomes true when user requests the activity to terminate
context: Option<Context>, // Context holder
config_cli: Option<ConfigClient>, // Config client
tab: SetupTab, // Current setup tab
popup: Option<Popup>, // Active popup
user_input: Vec<String>, // User input holder
user_input_ptr: usize, // Selected user input
quit_opt: QuitDialogOption, // Popup::Quit selected option
yesno_opt: YesNoDialogOption, // Popup::YesNo selected option
ssh_key_idx: usize, // Index of selected ssh key in list
redraw: bool, // Redraw ui?
}
impl Default for SetupActivity {
fn default() -> Self {
// Initialize user input
let mut user_input_buffer: Vec<String> = Vec::with_capacity(16);
for _ in 0..16 {
user_input_buffer.push(String::new());
}
SetupActivity {
quit: false,
context: None,
config_cli: None,
tab: SetupTab::UserInterface(UserInterfaceInputField::TextEditor),
popup: None,
user_input: user_input_buffer, // Max 16
user_input_ptr: 0,
quit_opt: QuitDialogOption::Save,
yesno_opt: YesNoDialogOption::Yes,
ssh_key_idx: 0,
redraw: true, // Draw at first `on_draw`
}
}
}
impl Activity for SetupActivity {
/// ### on_create
///
/// `on_create` is the function which must be called to initialize the activity.
/// `on_create` must initialize all the data structures used by the activity
/// Context is taken from activity manager and will be released only when activity is destroyed
fn on_create(&mut self, context: Context) {
// Set context
self.context = Some(context);
// Clear terminal
self.context.as_mut().unwrap().clear_screen();
// Put raw mode on enabled
let _ = enable_raw_mode();
// Initialize config client
if self.config_cli.is_none() {
self.init_config_client();
}
}
/// ### on_draw
///
/// `on_draw` is the function which draws the graphical interface.
/// This function must be called at each tick to refresh the interface
fn on_draw(&mut self) {
// Context must be something
if self.context.is_none() {
return;
}
// Read one event
if let Ok(Some(event)) = self.context.as_ref().unwrap().input_hnd.read_event() {
// Set redraw to true
self.redraw = true;
// Handle event
self.handle_input_event(&event);
}
// Redraw if necessary
if self.redraw {
// Draw
self.draw();
// Redraw back to false
self.redraw = false;
}
}
/// ### on_destroy
///
/// `on_destroy` is the function which cleans up runtime variables and data before terminating the activity.
/// This function must be called once before terminating the activity.
/// This function finally releases the context
fn on_destroy(&mut self) -> Option<Context> {
// Disable raw mode
let _ = disable_raw_mode();
self.context.as_ref()?;
// Clear terminal and return
match self.context.take() {
Some(mut ctx) => {
ctx.clear_screen();
Some(ctx)
}
None => None,
}
}
}

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -32,10 +32,10 @@ use super::input::InputHandler;
use crate::host::Localhost;
// Includes
use crossterm::execute;
use crossterm::event::DisableMouseCapture;
use crossterm::execute;
use crossterm::terminal::{EnterAlternateScreen, LeaveAlternateScreen};
use std::io::{stdout, Stdout, Write};
use std::io::{stdout, Stdout};
use tui::backend::CrosstermBackend;
use tui::Terminal;
@@ -59,9 +59,29 @@ impl Context {
Context {
local,
input_hnd: InputHandler::new(),
terminal: Terminal::new(CrosstermBackend::new(stdout)).unwrap()
terminal: Terminal::new(CrosstermBackend::new(stdout)).unwrap(),
}
}
pub fn enter_alternate_screen(&mut self) {
let _ = execute!(
self.terminal.backend_mut(),
EnterAlternateScreen,
DisableMouseCapture
);
}
pub fn leave_alternate_screen(&mut self) {
let _ = execute!(
self.terminal.backend_mut(),
LeaveAlternateScreen,
DisableMouseCapture
);
}
pub fn clear_screen(&mut self) {
let _ = self.terminal.clear();
}
}
impl Drop for Context {

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
@@ -45,6 +45,7 @@ impl InputHandler {
/// ### fetch_events
///
/// Check if new events have been received from handler
#[allow(dead_code)]
pub(crate) fn fetch_events(&self) -> Result<Vec<Event>, ()> {
let mut inbox: Vec<Event> = Vec::new();
loop {

View File

@@ -4,7 +4,7 @@
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*

View File

@@ -1,395 +0,0 @@
//! ## Utils
//!
//! `utils` is the module which provides utilities of different kind
/*
*
* Copyright (C) 2020 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Dependencies
extern crate chrono;
extern crate whoami;
use crate::filetransfer::FileTransferProtocol;
use chrono::format::ParseError;
use chrono::prelude::*;
use std::time::{Duration, SystemTime};
/// ### parse_remote_opt
///
/// Parse remote option string. Returns in case of success a tuple made of (address, port, protocol, username)
/// For ssh if username is not provided, current user will be used.
/// In case of error, message is returned
/// If port is missing default port will be used for each protocol
/// SFTP => 22
/// FTP => 21
/// The option string has the following syntax
/// [protocol]://[username]@{address}:[port]
/// The only argument which is mandatory is address
/// NOTE: possible strings
/// - 172.26.104.1
/// - root@172.26.104.1
/// - sftp://root@172.26.104.1
/// - sftp://172.26.104.1:4022
/// - sftp://172.26.104.1
/// - ...
///
pub fn parse_remote_opt(
remote: &str,
) -> Result<(String, u16, FileTransferProtocol, Option<String>), String> {
let mut wrkstr: String = remote.to_string();
let address: String;
let mut port: u16 = 22;
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp;
let mut username: Option<String> = None;
// Split string by '://'
let tokens: Vec<&str> = wrkstr.split("://").collect();
// If length is > 1, then token[0] is protocol
match tokens.len() {
1 => {}
2 => {
// Parse protocol
match tokens[0] {
"sftp" => {
// Set protocol to sftp
protocol = FileTransferProtocol::Sftp;
// Set port to default (22)
port = 22;
}
"scp" => {
// Set protocol to scp
protocol = FileTransferProtocol::Scp;
// Set port to default (22)
port = 22;
}
"ftp" => {
// Set protocol to fpt
protocol = FileTransferProtocol::Ftp(false);
// Set port to default (21)
port = 21;
}
"ftps" => {
// Set protocol to fpt
protocol = FileTransferProtocol::Ftp(true);
// Set port to default (21)
port = 21;
}
_ => return Err(format!("Unknown protocol '{}'", tokens[0])),
}
wrkstr = String::from(tokens[1]); // Wrkstr becomes tokens[1]
}
_ => return Err(String::from("Bad syntax")), // Too many tokens...
}
// Set username to default if sftp
if protocol == FileTransferProtocol::Sftp {
// Set username to current username
username = Some(whoami::username());
}
// Split wrkstring by '@'
let tokens: Vec<&str> = wrkstr.split('@').collect();
match tokens.len() {
1 => {}
2 => {
// Username is first token
username = Some(String::from(tokens[0]));
// Update wrkstr
wrkstr = String::from(tokens[1]);
}
_ => return Err(String::from("Bad syntax")), // Too many tokens...
}
// Split wrkstring by ':'
let tokens: Vec<&str> = wrkstr.split(':').collect();
match tokens.len() {
1 => {
// Address is wrkstr
address = wrkstr.clone();
}
2 => {
// Address is first token
address = String::from(tokens[0]);
// Port is second str
port = match tokens[1].parse::<u16>() {
Ok(val) => val,
Err(_) => {
return Err(format!(
"Port must be a number in range [0-65535], but is '{}'",
tokens[1]
))
}
};
}
_ => return Err(String::from("Bad syntax")), // Too many tokens...
}
Ok((address, port, protocol, username))
}
/// ### fmt_pex
///
/// Convert 3 bytes of permissions value into ls notation (e.g. rwx-wx--x)
pub fn fmt_pex(owner: u8, group: u8, others: u8) -> String {
let mut mode: String = String::with_capacity(9);
let read: u8 = (owner >> 2) & 0x1;
let write: u8 = (owner >> 1) & 0x1;
let exec: u8 = owner & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
let read: u8 = (group >> 2) & 0x1;
let write: u8 = (group >> 1) & 0x1;
let exec: u8 = group & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
let read: u8 = (others >> 2) & 0x1;
let write: u8 = (others >> 1) & 0x1;
let exec: u8 = others & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
mode
}
/// ### instant_to_str
///
/// Format a `Instant` into a time string
pub fn time_to_str(time: SystemTime, fmt: &str) -> String {
let datetime: DateTime<Local> = time.into();
format!("{}", datetime.format(fmt))
}
/// ### lstime_to_systime
///
/// Convert ls syntax time to System Time
/// ls time has two possible syntax:
/// 1. if year is current: %b %d %H:%M (e.g. Nov 5 13:46)
/// 2. else: %b %d %Y (e.g. Nov 5 2019)
pub fn lstime_to_systime(
tm: &str,
fmt_year: &str,
fmt_hours: &str,
) -> Result<SystemTime, ParseError> {
let datetime: NaiveDateTime = match NaiveDate::parse_from_str(tm, fmt_year) {
Ok(date) => {
// Case 2.
// Return NaiveDateTime from NaiveDate with time 00:00:00
date.and_hms(0, 0, 0)
}
Err(_) => {
// Might be case 1.
// We need to add Current Year at the end of the string
let this_year: i32 = Utc::now().year();
let date_time_str: String = format!("{} {}", tm, this_year);
// Now parse
match NaiveDateTime::parse_from_str(
date_time_str.as_ref(),
format!("{} %Y", fmt_hours).as_ref(),
) {
Ok(dt) => dt,
Err(err) => return Err(err),
}
}
};
// Convert datetime to system time
let sys_time: SystemTime = SystemTime::UNIX_EPOCH;
Ok(sys_time
.checked_add(Duration::from_secs(datetime.timestamp() as u64))
.unwrap_or(SystemTime::UNIX_EPOCH))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_parse_remote_opt() {
// Base case
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 22);
assert_eq!(result.2, FileTransferProtocol::Sftp);
assert!(result.3.is_some());
// User case
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("root@172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 22);
assert_eq!(result.2, FileTransferProtocol::Sftp);
assert_eq!(result.3.unwrap(), String::from("root"));
// User + port
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("root@172.26.104.1:8022"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 8022);
assert_eq!(result.2, FileTransferProtocol::Sftp);
assert_eq!(result.3.unwrap(), String::from("root"));
// Port only
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("172.26.104.1:4022"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 4022);
assert_eq!(result.2, FileTransferProtocol::Sftp);
assert!(result.3.is_some());
// Protocol
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("ftp://172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 21); // Fallback to ftp default
assert_eq!(result.2, FileTransferProtocol::Ftp(false));
assert!(result.3.is_none()); // Doesn't fall back
// Protocol
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("scp://172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 22); // Fallback to scp default
assert_eq!(result.2, FileTransferProtocol::Scp);
assert!(result.3.is_none()); // Doesn't fall back
// Protocol + user
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("ftps://anon@172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 21); // Fallback to ftp default
assert_eq!(result.2, FileTransferProtocol::Ftp(true));
assert_eq!(result.3.unwrap(), String::from("anon"));
// All together now
let result: (String, u16, FileTransferProtocol, Option<String>) =
parse_remote_opt(&String::from("ftp://anon@172.26.104.1:8021"))
.ok()
.unwrap();
assert_eq!(result.0, String::from("172.26.104.1"));
assert_eq!(result.1, 8021); // Fallback to ftp default
assert_eq!(result.2, FileTransferProtocol::Ftp(false));
assert_eq!(result.3.unwrap(), String::from("anon"));
// bad syntax
assert!(parse_remote_opt(&String::from("://172.26.104.1")).is_err()); // Missing protocol
assert!(parse_remote_opt(&String::from("omar://172.26.104.1")).is_err()); // Bad protocol
assert!(parse_remote_opt(&String::from("172.26.104.1:abc")).is_err()); // Bad port
}
#[test]
fn test_utils_fmt_pex() {
assert_eq!(fmt_pex(7, 7, 7), String::from("rwxrwxrwx"));
assert_eq!(fmt_pex(7, 5, 5), String::from("rwxr-xr-x"));
assert_eq!(fmt_pex(6, 6, 6), String::from("rw-rw-rw-"));
assert_eq!(fmt_pex(6, 4, 4), String::from("rw-r--r--"));
assert_eq!(fmt_pex(6, 0, 0), String::from("rw-------"));
assert_eq!(fmt_pex(0, 0, 0), String::from("---------"));
assert_eq!(fmt_pex(4, 4, 4), String::from("r--r--r--"));
assert_eq!(fmt_pex(1, 2, 1), String::from("--x-w---x"));
}
#[test]
fn test_utils_time_to_str() {
let system_time: SystemTime = SystemTime::from(SystemTime::UNIX_EPOCH);
assert_eq!(
time_to_str(system_time, "%Y-%m-%d"),
String::from("1970-01-01")
);
}
#[test]
fn test_utils_lstime_to_systime() {
// Good cases
assert_eq!(
lstime_to_systime("Nov 5 16:32", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1604593920)
);
assert_eq!(
lstime_to_systime("Dec 2 21:32", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1606944720)
);
assert_eq!(
lstime_to_systime("Nov 5 2018", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1541376000)
);
assert_eq!(
lstime_to_systime("Mar 18 2018", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1521331200)
);
// bad cases
assert!(lstime_to_systime("Oma 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
assert!(lstime_to_systime("Feb 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
assert!(lstime_to_systime("Feb 15 25:32", "%b %d %Y", "%b %d %H:%M").is_err());
}
}

67
src/utils/crypto.rs Normal file
View File

@@ -0,0 +1,67 @@
//! ## Crypto
//!
//! `crypto` is the module which provides utilities for crypting
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate magic_crypt;
// Ext
use magic_crypt::MagicCryptTrait;
/// ### aes128_b64_crypt
///
/// Crypt a string using AES128; output is returned as a BASE64 string
pub fn aes128_b64_crypt(key: &str, input: &str) -> String {
let crypter = new_magic_crypt!(key.to_string(), 128);
crypter.encrypt_str_to_base64(input.to_string())
}
/// ### aes128_b64_decrypt
///
/// Decrypt a string using AES128
pub fn aes128_b64_decrypt(key: &str, secret: &str) -> Result<String, magic_crypt::MagicCryptError> {
let crypter = new_magic_crypt!(key.to_string(), 128);
crypter.decrypt_base64_to_string(secret.to_string())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_crypto_aes128() {
let key: &str = "MYSUPERSECRETKEY";
let input: &str = "Hello world!";
let secret: String = aes128_b64_crypt(&key, input);
assert_eq!(secret.as_str(), "z4Z6LpcpYqBW4+bkIok+5A==");
assert_eq!(
aes128_b64_decrypt(key, secret.as_str())
.ok()
.unwrap()
.as_str(),
input
);
}
}

218
src/utils/fmt.rs Normal file
View File

@@ -0,0 +1,218 @@
//! ## Fmt
//!
//! `fmt` is the module which provides utilities for formatting
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
extern crate chrono;
extern crate textwrap;
use chrono::prelude::*;
use std::path::{Path, PathBuf};
use std::time::{Duration, SystemTime};
/// ### fmt_pex
///
/// Convert 3 bytes of permissions value into ls notation (e.g. rwx-wx--x)
pub fn fmt_pex(owner: u8, group: u8, others: u8) -> String {
let mut mode: String = String::with_capacity(9);
let read: u8 = (owner >> 2) & 0x1;
let write: u8 = (owner >> 1) & 0x1;
let exec: u8 = owner & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
let read: u8 = (group >> 2) & 0x1;
let write: u8 = (group >> 1) & 0x1;
let exec: u8 = group & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
let read: u8 = (others >> 2) & 0x1;
let write: u8 = (others >> 1) & 0x1;
let exec: u8 = others & 0x1;
mode.push_str(match read {
1 => "r",
_ => "-",
});
mode.push_str(match write {
1 => "w",
_ => "-",
});
mode.push_str(match exec {
1 => "x",
_ => "-",
});
mode
}
/// ### instant_to_str
///
/// Format a `Instant` into a time string
pub fn fmt_time(time: SystemTime, fmt: &str) -> String {
let datetime: DateTime<Local> = time.into();
format!("{}", datetime.format(fmt))
}
/// ### fmt_millis
///
/// Format duration as {secs}.{millis}
pub fn fmt_millis(duration: Duration) -> String {
let seconds: u128 = duration.as_millis() / 1000;
let millis: u128 = duration.as_millis() % 1000;
format!("{}.{:0width$}", seconds, millis, width = 3)
}
/// align_text_center
///
/// Align text to center for a given width
pub fn align_text_center(text: &str, width: u16) -> String {
let indent_size: usize = match (width as usize) >= text.len() {
// NOTE: The check prevents underflow
true => (width as usize - text.len()) / 2,
false => 0,
};
textwrap::indent(
text,
(0..indent_size).map(|_| " ").collect::<String>().as_str(),
)
.trim_end()
.to_string()
}
/// ### elide_path
///
/// Elide a path if longer than width
/// In this case, the path is formatted to {ANCESTOR[0]}/.../{PARENT[0]}/{BASENAME}
pub fn fmt_path_elide(p: &Path, width: usize) -> String {
let fmt_path: String = format!("{}", p.display());
match fmt_path.len() > width as usize {
false => fmt_path,
true => {
// Elide
let ancestors_len: usize = p.ancestors().count();
let mut ancestors = p.ancestors();
let mut elided_path: PathBuf = PathBuf::new();
// If ancestors_len's size is bigger than 2, push count - 2
if ancestors_len > 2 {
elided_path.push(ancestors.nth(ancestors_len - 2).unwrap());
}
// If ancestors_len is bigger than 3, push '...' and parent too
if ancestors_len > 3 {
elided_path.push("...");
if let Some(parent) = p.ancestors().nth(1) {
elided_path.push(parent.file_name().unwrap());
}
}
// Push file_name
if let Some(name) = p.file_name() {
elided_path.push(name);
}
format!("{}", elided_path.display())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_fmt_pex() {
assert_eq!(fmt_pex(7, 7, 7), String::from("rwxrwxrwx"));
assert_eq!(fmt_pex(7, 5, 5), String::from("rwxr-xr-x"));
assert_eq!(fmt_pex(6, 6, 6), String::from("rw-rw-rw-"));
assert_eq!(fmt_pex(6, 4, 4), String::from("rw-r--r--"));
assert_eq!(fmt_pex(6, 0, 0), String::from("rw-------"));
assert_eq!(fmt_pex(0, 0, 0), String::from("---------"));
assert_eq!(fmt_pex(4, 4, 4), String::from("r--r--r--"));
assert_eq!(fmt_pex(1, 2, 1), String::from("--x-w---x"));
}
#[test]
fn test_utils_fmt_time() {
let system_time: SystemTime = SystemTime::from(SystemTime::UNIX_EPOCH);
assert_eq!(
fmt_time(system_time, "%Y-%m-%d"),
String::from("1970-01-01")
);
}
#[test]
fn test_utils_align_text_center() {
assert_eq!(
align_text_center("hello world!", 24),
String::from(" hello world!")
);
// Bad case
assert_eq!(
align_text_center("hello world!", 8),
String::from("hello world!")
);
}
#[test]
fn test_utils_fmt_millis() {
assert_eq!(
fmt_millis(Duration::from_millis(2048)),
String::from("2.048")
);
assert_eq!(
fmt_millis(Duration::from_millis(8192)),
String::from("8.192")
);
assert_eq!(
fmt_millis(Duration::from_millis(18192)),
String::from("18.192")
);
}
#[test]
#[cfg(any(target_os = "unix", target_os = "linux", target_os = "macos"))]
fn test_utils_fmt_path_elide() {
let p: &Path = &Path::new("/develop/pippo");
// Under max size
assert_eq!(fmt_path_elide(p, 16), String::from("/develop/pippo"));
// Above max size, only one ancestor
assert_eq!(fmt_path_elide(p, 8), String::from("/develop/pippo"));
let p: &Path = &Path::new("/develop/pippo/foo/bar");
assert_eq!(fmt_path_elide(p, 16), String::from("/develop/.../foo/bar"));
}
}

85
src/utils/git.rs Normal file
View File

@@ -0,0 +1,85 @@
//! ## git
//!
//! `git` is the module which provides utilities to interact through the GIT API and to perform some stuff at git level
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate ureq;
// Locals
use super::parser::parse_semver;
// Others
use serde::Deserialize;
#[derive(Deserialize)]
struct TagInfo {
tag_name: String,
}
/// ### check_for_updates
///
/// Check if there is a new version available for termscp.
/// This is performed through the Github API
/// In case of success returns Ok(Option<String>), where the Option is Some(new_version); otherwise if no version is available, return None
/// In case of error returns Error with the error description
pub fn check_for_updates(current_version: &str) -> Result<Option<String>, String> {
// Send request
let github_version: Result<String, String> =
match ureq::get("https://api.github.com/repos/veeso/termscp/releases/latest").call() {
Ok(response) => match response.into_json::<TagInfo>() {
Ok(tag_info) => Ok(tag_info.tag_name),
Err(err) => Err(err.to_string()),
},
Err(err) => Err(err.to_string()),
};
// Check version
match github_version {
Err(err) => Err(err),
Ok(version) => {
// Parse version
match parse_semver(version.as_str()) {
Some(new_version) => {
// Check if version is different
if new_version.as_str() > current_version {
Ok(Some(new_version)) // New version is available
} else {
Ok(None) // No new version
}
}
None => Err(String::from("Got bad response from Github")),
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_git_check_for_updates() {
assert!(check_for_updates("100.0.0").ok().unwrap().is_none());
assert!(check_for_updates("0.0.1").ok().unwrap().is_some());
}
}

31
src/utils/mod.rs Normal file
View File

@@ -0,0 +1,31 @@
//! ## Utils
//!
//! `utils` is the module which provides utilities of different kind
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// modules
pub mod crypto;
pub mod fmt;
pub mod git;
pub mod parser;
pub mod random;

418
src/utils/parser.rs Normal file
View File

@@ -0,0 +1,418 @@
//! ## Parser
//!
//! `parser` is the module which provides utilities for parsing different kind of stuff
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Dependencies
extern crate chrono;
extern crate regex;
extern crate whoami;
// Locals
use crate::filetransfer::FileTransferProtocol;
#[cfg(not(test))] // NOTE: don't use configuration during tests
use crate::system::config_client::ConfigClient;
#[cfg(not(test))] // NOTE: don't use configuration during tests
use crate::system::environment;
// Ext
use chrono::format::ParseError;
use chrono::prelude::*;
use regex::Regex;
use std::path::PathBuf;
use std::str::FromStr;
use std::time::{Duration, SystemTime};
// Regex
lazy_static! {
/**
* Regex matches:
* - group 1: Some(protocol) | None
* - group 2: Some(user) | None
* - group 3: Address
* - group 4: Some(port) | None
* - group 5: Some(path) | None
*/
static ref REMOTE_OPT_REGEX: Regex = Regex::new(r"(?:([a-z]+)://)?(?:([^@]+)@)?(?:([^:]+))(?::((?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])(?:[0-9]{1,4}|[1-5][0-9]{4}|6[0-4][0-9]{3}|65[0-4][0-9]{2}|655[0-2][0-9]|6553[0-5])))?(?::([^:]+))?").ok().unwrap();
/**
* Regex matches:
* - group 1: Version
* E.g. termscp-0.3.2 => 0.3.2
* v0.4.0 => 0.4.0
*/
static ref SEMVER_REGEX: Regex = Regex::new(r".*(:?[0-9]\.[0-9]\.[0-9])").unwrap();
}
pub struct RemoteOptions {
pub hostname: String,
pub port: u16,
pub protocol: FileTransferProtocol,
pub username: Option<String>,
pub wrkdir: Option<PathBuf>,
}
/// ### parse_remote_opt
///
/// 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
/// If port is missing default port will be used for each protocol
/// SFTP => 22
/// FTP => 21
/// The option string has the following syntax
/// [protocol://][username@]{address}[:port][:path]
/// The only argument which is mandatory is address
/// NOTE: possible strings
/// - 172.26.104.1
/// - root@172.26.104.1
/// - sftp://root@172.26.104.1
/// - sftp://172.26.104.1:4022
/// - sftp://172.26.104.1
/// - ...
///
pub fn parse_remote_opt(remote: &str) -> Result<RemoteOptions, String> {
// Set protocol to default protocol
#[cfg(not(test))] // NOTE: don't use configuration during tests
let mut protocol: FileTransferProtocol = match environment::init_config_dir() {
Ok(p) => match p {
Some(p) => {
// Create config client
let (config_path, ssh_key_path) = environment::get_config_paths(p.as_path());
match ConfigClient::new(config_path.as_path(), ssh_key_path.as_path()) {
Ok(cli) => cli.get_default_protocol(),
Err(_) => FileTransferProtocol::Sftp,
}
}
None => FileTransferProtocol::Sftp,
},
Err(_) => FileTransferProtocol::Sftp,
};
#[cfg(test)] // NOTE: during test set protocol just to Sftp
let mut protocol: FileTransferProtocol = FileTransferProtocol::Sftp;
// Match against regex
match REMOTE_OPT_REGEX.captures(remote) {
Some(groups) => {
// Match protocol
let mut port: u16 = 22;
if let Some(group) = groups.get(1) {
// Set protocol from group
let (m_protocol, m_port) = match FileTransferProtocol::from_str(group.as_str()) {
Ok(proto) => match proto {
FileTransferProtocol::Ftp(_) => (proto, 21),
FileTransferProtocol::Scp => (proto, 22),
FileTransferProtocol::Sftp => (proto, 22),
},
Err(_) => return Err(format!("Unknown protocol \"{}\"", group.as_str())),
};
// NOTE: tuple destructuring assignment is not supported yet :(
protocol = m_protocol;
port = m_port;
}
// Match user
let username: Option<String> = match groups.get(2) {
Some(group) => Some(group.as_str().to_string()),
None => match protocol {
// If group is empty, set to current user
FileTransferProtocol::Scp | FileTransferProtocol::Sftp => {
Some(whoami::username())
}
_ => None,
},
};
// Get address
let hostname: String = match groups.get(3) {
Some(group) => group.as_str().to_string(),
None => return Err(String::from("Missing address")),
};
// Get port
if let Some(group) = groups.get(4) {
port = match group.as_str().parse::<u16>() {
Ok(p) => p,
Err(err) => return Err(format!("Bad port \"{}\": {}", group.as_str(), err)),
};
}
// Get workdir
let wrkdir: Option<PathBuf> = match groups.get(5) {
Some(group) => Some(PathBuf::from(group.as_str())),
None => None,
};
Ok(RemoteOptions {
hostname,
port,
protocol,
username,
wrkdir,
})
}
None => Err(String::from("Bad remote host syntax!")),
}
}
/// ### parse_lstime
///
/// Convert ls syntax time to System Time
/// ls time has two possible syntax:
/// 1. if year is current: %b %d %H:%M (e.g. Nov 5 13:46)
/// 2. else: %b %d %Y (e.g. Nov 5 2019)
pub fn parse_lstime(tm: &str, fmt_year: &str, fmt_hours: &str) -> Result<SystemTime, ParseError> {
let datetime: NaiveDateTime = match NaiveDate::parse_from_str(tm, fmt_year) {
Ok(date) => {
// Case 2.
// Return NaiveDateTime from NaiveDate with time 00:00:00
date.and_hms(0, 0, 0)
}
Err(_) => {
// Might be case 1.
// We need to add Current Year at the end of the string
let this_year: i32 = Utc::now().year();
let date_time_str: String = format!("{} {}", tm, this_year);
// Now parse
match NaiveDateTime::parse_from_str(
date_time_str.as_ref(),
format!("{} %Y", fmt_hours).as_ref(),
) {
Ok(dt) => dt,
Err(err) => return Err(err),
}
}
};
// Convert datetime to system time
let sys_time: SystemTime = SystemTime::UNIX_EPOCH;
Ok(sys_time
.checked_add(Duration::from_secs(datetime.timestamp() as u64))
.unwrap_or(SystemTime::UNIX_EPOCH))
}
/// ### parse_datetime
///
/// Parse date time string representation and transform it into `SystemTime`
pub fn parse_datetime(tm: &str, fmt: &str) -> Result<SystemTime, ParseError> {
match NaiveDateTime::parse_from_str(tm, fmt) {
Ok(dt) => {
let sys_time: SystemTime = SystemTime::UNIX_EPOCH;
Ok(sys_time
.checked_add(Duration::from_secs(dt.timestamp() as u64))
.unwrap_or(SystemTime::UNIX_EPOCH))
}
Err(err) => Err(err),
}
}
/// ### parse_semver
///
/// Parse semver string
pub fn parse_semver(haystack: &str) -> Option<String> {
match SEMVER_REGEX.captures(haystack) {
Some(groups) => match groups.get(1) {
Some(version) => Some(version.as_str().to_string()),
None => None,
},
None => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::utils::fmt::fmt_time;
#[test]
fn test_utils_parse_remote_opt() {
// Base case
let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 22);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert!(result.username.is_some());
// User case
let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 22);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert_eq!(result.username.unwrap(), String::from("root"));
assert!(result.wrkdir.is_none());
// User + port
let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1:8022"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 8022);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert_eq!(result.username.unwrap(), String::from("root"));
assert!(result.wrkdir.is_none());
// Port only
let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1:4022"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 4022);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert!(result.username.is_some());
assert!(result.wrkdir.is_none());
// Protocol
let result: RemoteOptions = parse_remote_opt(&String::from("ftp://172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 21); // Fallback to ftp default
assert_eq!(result.protocol, FileTransferProtocol::Ftp(false));
assert!(result.username.is_none()); // Doesn't fall back
assert!(result.wrkdir.is_none());
// Protocol
let result: RemoteOptions = parse_remote_opt(&String::from("sftp://172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 22); // Fallback to sftp default
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert!(result.username.is_some()); // Doesn't fall back
assert!(result.wrkdir.is_none());
let result: RemoteOptions = parse_remote_opt(&String::from("scp://172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 22); // Fallback to scp default
assert_eq!(result.protocol, FileTransferProtocol::Scp);
assert!(result.username.is_some()); // Doesn't fall back
assert!(result.wrkdir.is_none());
// Protocol + user
let result: RemoteOptions = parse_remote_opt(&String::from("ftps://anon@172.26.104.1"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 21); // Fallback to ftp default
assert_eq!(result.protocol, FileTransferProtocol::Ftp(true));
assert_eq!(result.username.unwrap(), String::from("anon"));
assert!(result.wrkdir.is_none());
// Path
let result: RemoteOptions = parse_remote_opt(&String::from("root@172.26.104.1:8022:/var"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 8022);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert_eq!(result.username.unwrap(), String::from("root"));
assert_eq!(result.wrkdir.unwrap(), PathBuf::from("/var"));
// Port only
let result: RemoteOptions = parse_remote_opt(&String::from("172.26.104.1:home"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 22);
assert_eq!(result.protocol, FileTransferProtocol::Sftp);
assert!(result.username.is_some());
assert_eq!(result.wrkdir.unwrap(), PathBuf::from("home"));
// All together now
let result: RemoteOptions =
parse_remote_opt(&String::from("ftp://anon@172.26.104.1:8021:/tmp"))
.ok()
.unwrap();
assert_eq!(result.hostname, String::from("172.26.104.1"));
assert_eq!(result.port, 8021); // Fallback to ftp default
assert_eq!(result.protocol, FileTransferProtocol::Ftp(false));
assert_eq!(result.username.unwrap(), String::from("anon"));
assert_eq!(result.wrkdir.unwrap(), PathBuf::from("/tmp"));
// bad syntax
assert!(parse_remote_opt(&String::from("omar://172.26.104.1")).is_err()); // Bad protocol
assert!(parse_remote_opt(&String::from("omar://172.26.104.1:650000")).is_err());
// Bad port
}
#[test]
fn test_utils_parse_lstime() {
// Good cases
assert_eq!(
fmt_time(
parse_lstime("Nov 5 16:32", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap(),
"%m %d %M"
)
.as_str(),
"11 05 32"
);
assert_eq!(
fmt_time(
parse_lstime("Dec 2 21:32", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap(),
"%m %d %M"
)
.as_str(),
"12 02 32"
);
assert_eq!(
parse_lstime("Nov 5 2018", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1541376000)
);
assert_eq!(
parse_lstime("Mar 18 2018", "%b %d %Y", "%b %d %H:%M")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1521331200)
);
// bad cases
assert!(parse_lstime("Oma 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
assert!(parse_lstime("Feb 31 2018", "%b %d %Y", "%b %d %H:%M").is_err());
assert!(parse_lstime("Feb 15 25:32", "%b %d %Y", "%b %d %H:%M").is_err());
}
#[test]
fn test_utils_parse_datetime() {
assert_eq!(
parse_datetime("04-08-14 03:09PM", "%d-%m-%y %I:%M%p")
.ok()
.unwrap()
.duration_since(SystemTime::UNIX_EPOCH)
.ok()
.unwrap(),
Duration::from_secs(1407164940)
);
// Not enough argument for datetime
assert!(parse_datetime("04-08-14", "%d-%m-%y").is_err());
}
#[test]
fn test_utils_parse_semver() {
assert_eq!(
parse_semver("termscp-0.3.2").unwrap(),
String::from("0.3.2")
);
assert_eq!(parse_semver("v0.4.1").unwrap(), String::from("0.4.1"),);
assert_eq!(parse_semver("1.0.0").unwrap(), String::from("1.0.0"),);
assert!(parse_semver("v1.1").is_none());
}
}

52
src/utils/random.rs Normal file
View File

@@ -0,0 +1,52 @@
//! ## Random
//!
//! `random` is the module which provides utilities for rand
/*
*
* Copyright (C) 2020-2021 Christian Visintin - christian.visintin1997@gmail.com
*
* This file is part of "TermSCP"
*
* TermSCP is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* TermSCP is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with TermSCP. If not, see <http://www.gnu.org/licenses/>.
*
*/
// Deps
extern crate rand;
// Ext
use rand::{distributions::Alphanumeric, thread_rng, Rng};
/// ## random_alphanumeric_with_len
///
/// Generate a random alphanumeric string with provided length
pub fn random_alphanumeric_with_len(len: usize) -> String {
let mut rng = thread_rng();
std::iter::repeat(())
.map(|()| rng.sample(Alphanumeric))
.map(char::from)
.take(len)
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_utils_random_alphanumeric_with_len() {
assert_eq!(random_alphanumeric_with_len(256).len(), 256);
}
}