├── .github └── FUNDING.yml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── LICENSE ├── README.md ├── bacon.toml ├── build.sh ├── compile-all-targets.sh ├── doc ├── clear-drawer.png ├── drawer-creation.png ├── drawer-opening.png ├── help.png ├── multiline.png ├── new-closet.png ├── new-drawer.png ├── search.png └── typing-entry.png ├── fmt.sh ├── release.sh ├── rustfmt.toml ├── src ├── cli │ ├── args.rs │ └── mod.rs ├── core │ ├── closed_drawer.rs │ ├── closet.rs │ ├── core_error.rs │ ├── drawer_content.rs │ ├── drawer_id.rs │ ├── drawer_settings.rs │ ├── entry.rs │ ├── mod.rs │ ├── open_closet.rs │ ├── open_drawer.rs │ └── random.rs ├── csv │ └── mod.rs ├── error.rs ├── import │ ├── import_set.rs │ └── mod.rs ├── main.rs ├── search │ ├── fuzzy_pattern.rs │ ├── mod.rs │ ├── name_match.rs │ └── pos.rs ├── timer │ └── mod.rs └── tui │ ├── action.rs │ ├── app.rs │ ├── app_state.rs │ ├── cmd_result.rs │ ├── comments_editor │ ├── comments_editor_state.rs │ ├── comments_editor_view.rs │ └── mod.rs │ ├── content_view.rs │ ├── dialog.rs │ ├── drawer_drawing_layout.rs │ ├── drawer_focus.rs │ ├── drawer_state.rs │ ├── entry_ref.rs │ ├── file_selector │ ├── file_selector_state.rs │ ├── file_selector_view.rs │ ├── file_type.rs │ └── mod.rs │ ├── global_view.rs │ ├── help.rs │ ├── help_content.rs │ ├── import │ ├── choices.rs │ ├── decide_origin_kind.rs │ ├── import_state.rs │ ├── import_view.rs │ └── mod.rs │ ├── keys.rs │ ├── matched_string.rs │ ├── menu │ ├── action_menu.rs │ ├── confirm.rs │ ├── inform.rs │ ├── menu.rs │ ├── menu_state.rs │ ├── menu_view.rs │ └── mod.rs │ ├── message.rs │ ├── mod.rs │ ├── password_dialog │ ├── mod.rs │ ├── password_dialog_purpose.rs │ ├── password_dialog_state.rs │ └── password_dialog_view.rs │ ├── scroll.rs │ ├── search_state.rs │ ├── skin │ ├── app_skin.rs │ ├── content_skin.rs │ ├── dialog_skin.rs │ ├── mod.rs │ └── status_skin.rs │ ├── status_view.rs │ ├── task.rs │ ├── title_view.rs │ └── view.rs ├── version.sh └── website ├── .gitignore ├── README.md ├── custom_theme ├── main.html └── toc.html ├── deploy.sh ├── docs ├── README.md ├── community.md ├── css │ ├── extra.css │ └── link-to-dystroy.css ├── features.md ├── img │ ├── clear-drawer.png │ ├── drawer-creation.png │ ├── drawer-opening.png │ ├── dystroy-rust-white.svg │ ├── favicon.ico │ ├── favicon.png │ ├── help.png │ ├── logo-safecloset.png │ ├── lyon.jpg │ ├── menu.png │ ├── multiline.png │ ├── new-closet.png │ ├── new-drawer.png │ ├── search-ca.png │ ├── search.png │ └── typing-entry.png ├── index.md ├── install.md ├── js │ ├── autoclose-tree.js │ └── link-to-dystroy.js └── usage.md └── mkdocs.yml /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [Canop] 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .bacon-locations 2 | /target 3 | /*.closet 4 | /*.old 5 | /*.zip 6 | /build 7 | /releases 8 | /deploy.sh 9 | /*-deploy.sh 10 | /*-test.sh 11 | /trav 12 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ### v1.4.0 - 2025-05-31 3 | - change double-click behavior to select all non-space characters around instead of selecting only "word characters" - This is more convenient when the text you want to select is an email or a password 4 | 5 | 6 | ### v1.3.2 - 2023-11-11 7 | - update some dependencies 8 | 9 | 10 | ### v1.3.1 - 2023-09-06 11 | - fix shift-character extending the selection 12 | 13 | 14 | ### v1.3.0 - 2023-06-15 15 | - Sort, accessible through the menu - Fix #31 16 | - Group matching entries, available in the menu when there's a search and more than one matching entries 17 | - ctrl-up and ctrl-down now available for moving entries even when the list is filtered - Fix #34 18 | 19 | 20 | ### v1.2.3 - 2023-04-06 21 | Fix a crash on some combinations of scroll+search 22 | 23 | 24 | ### v1.2.2 - 2023-04-03 25 | Better clipboard support on Mac - Fix #30 26 | 27 | 28 | ### v1.2.1 - 2023-03-10 29 | Values aren't rendered as markdown by default anymore. Markdown rendering is now opt-in, through the drawer menu - Fix #27,#28 30 | 31 | 32 | ### v1.1.0 - 2023-02-28 33 | Import feature: lets you import from another drawer, from a drawer of another closet file, or from a CSV file. 34 | 35 | 36 | ### v1.0.0 - 2023-01-13 37 | There's no reason to wait more for a 1.0.0: SafeCloset is stable and complete. 38 | 39 | 40 | ### v0.7.0 - 2022-10-18 41 | - shift-n key combination for adding an entry immediately after the selected one 42 | 43 | 44 | ### v0.6.4 - 2022-09-20 45 | - fix backslash character ('\') not rendered in values - Fix #22 46 | - colorize bold so that it's visible in terminal not rendering bold as bold 47 | - I don't provide the Raspberry precompiled version anymore due to a difficulty with cargo cross 48 | 49 | 50 | ### v0.6.3 - 2022-08-30 51 | - increase delay before auto closing to 120 seconds 52 | - fix tab key active behind open menu - Fix #21 53 | 54 | 55 | ### v0.6.2 - 2022-05-05 56 | - update termimad to solve a potential crash 57 | 58 | 59 | ### v0.6.1 - 2022-01-16 60 | - more relevant contextual hints - Fix #20 61 | 62 | 63 | ### v0.6.0 - 2021-12-03 64 | - support for more mouse interactions 65 | - clear comments at start of closet file (example purpose is holding the name of the soft to find it if you have as bad a memory as me) 66 | - closet clear comments editor 67 | 68 | 69 | ### v0.5.3 - 2021-11-28 70 | - Fix crash on Windows Terminal on some mouse operations - Fix #17 71 | - better support of wide characters - Fix #18 72 | - update status when waiting for long tasks 73 | 74 | 75 | ### v0.5.2 - 2021-11-19 76 | - fix rendering problems on sides on Windows (eg duplicate status line) - Fix #14 77 | 78 | 79 | ### v0.5.1 - 2021-11-17 80 | - fix documentation on values folding in help screen 81 | 82 | 83 | ### v0.5.0 - 2021-11-17 84 | - change drawer password - Fix #5 85 | 86 | 87 | ### v0.4.0 - 2021-11-11 88 | - the esc key opens a menu displaying relevant commands and their keys 89 | - ctrl-A toggles having all values always open (choice is kept in drawer settings) - Fix #8 90 | - various improvements of ergonomics 91 | 92 | 93 | ### v0.3.0 - 2021-10-30 94 | - ctrl-x no longer saves and quits 95 | - clipboard feature now default 96 | - support of selection in inputs (with shift arrow keys or mouse drag) 97 | - ctrl-x, ctrl-c, ctrl-v are shortcuts for cutting, copying, pasting in inputs 98 | - more mouse support (for example mouse wheel in inputs) 99 | 100 | 101 | ### v0.2.6 - 2021-10-22 102 | - various improvements on focusing and unfocusing the search input 103 | - when editing a multiline value, ctrl-down and ctrl-up swap lines 104 | - improve suggestions in status bar 105 | 106 | 107 | ### v0.2.5 - 2021-10-05 108 | - closing a drawer (and going to the upper drawer) is now done with ctrl-u 109 | - on some platforms, ctrl-c copies the selected cell (if safecloset is compiled with "clipbpoard" feature) 110 | 111 | 112 | ### v0.2.4 - 2021-10-03 113 | - now both ctrl-enter and alt-enter can be used to insert a new line in a value (but many terminals support only one of them) 114 | 115 | 116 | ### v0.2.4 - 2021-10-03 117 | - now both ctrl-enter and alt-enter can be used to insert a new line in a value (but many terminals support only one of them) 118 | 119 | 120 | ### v0.2.3 - 2021-10-01 121 | - fix a crash on rendering with an empty value 122 | 123 | 124 | ### v0.2.2 - 2021-09-29 125 | - `-o` option to immediately prompt for password for drawer opening 126 | - ctrl-v pastes the content of the clipboard (if safecloset is compiled with "clipbpoard" feature) 127 | - mouse wheel support 128 | 129 | 130 | ### v0.2.1 - 2021-09-26 131 | - help screen 132 | - ctrl-c to close a drawer or the help screen 133 | - 'a' key edits a field, cursor at end, while 'i' puts the cursor at start 134 | 135 | 136 | ### v0.2.0 - 2021-09-19 137 | - mouse support in inputs and for cell selection 138 | - sub-drawers (and breaking change in closet format) 139 | 140 | 141 | ### v0.1.3 - 2021-09-08 142 | - quit on inactivity 143 | - swap entries with ctrl-up and ctrl-down 144 | - multi-line values 145 | 146 | 147 | ### v0.1.2 - 2021-08-24 148 | - fuzzy search 149 | 150 | 151 | ### v0.1.1 - 2021-08-24 152 | - password characters visibility toggle (hidden initially) 153 | - unselected values visibility toggle (preference kept in drawer, and automatic hiding if launched with --hide) 154 | - switched from JSON to MessagePack as serialization format (breaking change) 155 | - entry removal with the 'd' key 156 | 157 | 158 | ### v0.1.0 - 2021-08-23 159 | Yes it has a version, but it doesn't mean you can use it. Wait for the 0.2 at least! 160 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "safecloset" 3 | version = "1.4.0" 4 | edition = "2021" 5 | authors = ["dystroy "] 6 | repository = "https://github.com/Canop/safecloset" 7 | description = "Secret Keeper" 8 | keywords = ["secret", "tui", "cryptography", "password"] 9 | license = "AGPL-3.0" 10 | categories = ["command-line-interface", "cryptography"] 11 | readme = "README.md" 12 | rust-version = "1.62" 13 | 14 | [features] 15 | default = ["clipboard"] 16 | clipboard = ["terminal-clipboard"] 17 | 18 | [dependencies] 19 | aes-gcm-siv = "=0.11.1" 20 | argh = "=0.1.12" 21 | char_reader = "=0.1.1" 22 | cli-log = "=2.1.0" 23 | crokey = "1.2" 24 | crossbeam = "=0.8.4" 25 | once_cell = "1.21" 26 | rand = "=0.9.1" 27 | rmp-serde = "=1.3.0" 28 | rust-argon2 = "=0.8.3" 29 | secular = { version = "1.0.1", features = ["normalization"] } 30 | serde = { version = "1.0.219", features = ["derive"] } 31 | termimad = "=0.33.0" 32 | terminal-clipboard = { version = "=0.4.1", optional = true } 33 | thiserror = "=2.0.12" 34 | unicode-width = "=0.2.0" 35 | 36 | [dev-dependencies] 37 | tempfile = "=3.2.0" 38 | 39 | [profile.release] 40 | lto = true 41 | strip = true 42 | 43 | [patch.crates-io] 44 | # crokey = { path = "../crokey" } 45 | # coolor = { path = "../coolor" } 46 | # minimad = { path = "../minimad" } 47 | # termimad = { path = "../termimad" } 48 | # terminal-clipboard = { path = "../terminal-clipboard" } 49 | 50 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Latest Version][s1]][l1] [![Chat on Miaou][s2]][l2] 2 | 3 | [s1]: https://img.shields.io/crates/v/safecloset.svg 4 | [l1]: https://crates.io/crates/safecloset 5 | 6 | [s2]: https://miaou.dystroy.org/static/shields/room.svg 7 | [l2]: https://miaou.dystroy.org/3768?rust 8 | 9 | **SafeCloset** keeps your secrets in password protected files. 10 | **SafeCloset** is designed to be convenient and avoid common weaknesses like external editing or temporary files written on disk. 11 | 12 | 13 | **[SafeCloset documentation](https://dystroy.org/safecloset)** 14 | 15 | # Warning 16 | 17 | **SafeCloset** hasn't been independently audited and comes with **absolutely** no guarantee. 18 | And I can do nothing for you if you lose the secrets you stored in SafeCloset. 19 | 20 | # Overview 21 | 22 | A closet is stored in a file that you can backup, keep with you on an USB key, etc. 23 | 24 | A closet contains drawers, each one is found and open with its own password. 25 | 26 | A drawer contains a list of (key, value). Values are texts in which you can store a code, a password, comments, a poem, some data, etc. 27 | 28 | A drawer can also contain deeper crypted drawers. 29 | 30 | ![clear drawer](doc/clear-drawer.png) 31 | 32 | # Features 33 | 34 | * The closet contains several drawers, some of them automatically created with an unknown password so that nobody can determine which drawers you're able to open, or even how many 35 | * Each drawer is separately crypted with AES-GCM-SIV, with a random one-use nonce and the password/key of your choice. This gives an inherently long to test decrypt algorithm (but you should still use long passphrases for your drawers) 36 | * You can have one or several drawers with real content. You can be forced to open a drawer at gun point and still keep other drawers secret without any trace, either at the top level or deeper in the drawer you opened 37 | * When you open a drawer, with its password, you can read it, search it, edit it, close it 38 | * In an open drawer you can create new drawers, or open deeper drawers if you know their password 39 | * SafeCloset automatically quits on inactivity 40 | * The size of the drawer's content isn't observable 41 | * No clear file is ever created, edition is done directly in the TUI (external editors are usually the weakest point) 42 | * No clear data is ever given to any external library, widget, etc. 43 | * All data is viewed and edited in the TUI application 44 | * You can compile SafeCloset yourself. Its code is small and auditable 45 | * The code is 100% in Rust. I wouldn't trust anything else today for such a program 46 | * The format of the closet file is described so that another application could be written to decode your closet files in the future (assuming you have the password) 47 | * SafeCloset can't be queried by other applications, like browsers. This is a feature. 48 | * You may have all your secrets in one file easy to keep with you and backup 49 | * No company can die and lose your secrets: you keep everything, with as many copies as necessary, where you want 50 | * No company can be forced to add some secret stealing code: SafeCloset is small, open-source and repleacable 51 | * Fast and convenient to use - This is where the focus of the design was 52 | * Cross-platform because you don't know where you'll have to use your closet 53 | * "I'm being watched" mode in which unselected values are hidden. This mode is kept per drawer, always activated when you launch SafeCloset with the `--hide` option, and toggled with ctrlh 54 | 55 | # Non features 56 | 57 | * SafeCloset doesn't protect you against keyloggers 58 | * SafeCloset doesn't protect you from somebody watching your screen while a secret value is displayed (but the rest of the drawer can be kept hidden) 59 | 60 | # Usage 61 | 62 | *Those screenshots are small, to fit here, but you may use SafeCloset full screen if you want.* 63 | 64 | ## Create your closet file 65 | 66 | Run 67 | 68 | ```bash 69 | safecloset some/name.closet 70 | ``` 71 | 72 | ![new closet](doc/new-closet.png) 73 | 74 | ## Have a glance at the help 75 | 76 | Hit ? to go to the help screen, where you'll find the complete list of commands. 77 | 78 | ![help](doc/help.png) 79 | 80 | Hit esc to get back to the previous screen. 81 | 82 | ## Create your first drawer 83 | 84 | Hit ctrln 85 | 86 | ![drawer creation](doc/drawer-creation.png) 87 | 88 | ![new drawer](doc/new-drawer.png) 89 | 90 | If you want, you can create a deeper drawer there, at any time, by hitting ctrln. 91 | 92 | Or hit n to create a new entry, starting with its name then hitting tab to go fill its value. 93 | 94 | ![typing entry](doc/typing-entry.png) 95 | 96 | Change the selection with the arrow keys. 97 | Go from input to input with the tab key. Or edit the currently selected field with a. 98 | 99 | Reorder entries with ctrl🠕 and ctrl🠗. 100 | 101 | In SafeCloset, when editing, searching, opening, etc., the enter key validates the operation while the esc key cancels or closes. 102 | 103 | You may add newlines in values with ctrlenter or altenter: 104 | 105 | ![multiline](doc/multiline.png) 106 | 107 | *You may notice the values are rendered as Markdown.* 108 | 109 | Don't hesitate to store hundreds of secrets in the same drawer as you'll easily find them with the fuzzy search. 110 | 111 | Search with the / key: 112 | 113 | ![search](doc/search.png) 114 | 115 | When in the search input, remove the search with esc, freeze it with enter. 116 | 117 | ## Save and quit 118 | 119 | Hit ctrls to save, then ctrlq to quit. 120 | 121 | ## Reopen 122 | 123 | The same command is used later on to open the closet again: 124 | 125 | ```bash 126 | safecloset some/name.closet 127 | ``` 128 | 129 | It may be a good idea to define an alias so that you have your secrets easily available. 130 | You could for example have this in you `.bashrc`: 131 | 132 | ```bash 133 | function xx { 134 | safecloset -o ~/some/name.closet 135 | } 136 | ``` 137 | 138 | The `-o` argument makes safecloset immediately prompt for drawer password, so that you don't have to type ctrlo. 139 | 140 | On opening, just type the password of the drawer you want to open (all will be tested until the right one opens): 141 | 142 | ![drawer opening](doc/drawer-opening.png) 143 | 144 | # Storage format 145 | 146 | The storage format is described to ensure it's possible to replace SafeCloset with another software if needed. 147 | 148 | The closet file is a [MessagePack](https://msgpack.org/index.html) encoded structure `Closet` with the following fields: 149 | 150 | * `comments`: a string 151 | * `salt`: a string 152 | * `drawers`: an array of `ClosedDrawer` 153 | 154 | The MessagePack serialization preserves field names and allows future additions. 155 | 156 | An instance of `ClosedDrawer` is a structure with the following fields: 157 | 158 | * `id`: a byte array 159 | * `nonce`: a byte array 160 | * `content`: a byte array 161 | 162 | The `content` is the AES-GCM-SIV encryption of the serializied drawer with the included `nonce`. 163 | The key used for this encryption is a 256 bits Argon2 hash of the password with the closet's salt. 164 | 165 | The serialized drawer is a MessagePack encoded structure with the following fields: 166 | 167 | * `id`: a byte array 168 | * `entries`: an array of `Entry` 169 | * `settings`: an instance of `DrawerSettings` 170 | * `closet`: a deeper closet, containing drawers, etc. 171 | * `garbage`: a random byte array 172 | 173 | Instances of `Entry` contain the following fields: 174 | 175 | * `name`: a string 176 | * `value`: a string 177 | 178 | Instances of `DrawerSettings` contain the following fields: 179 | 180 | * `hide_values`: a boolean 181 | * `open_all_values`: a boolean (optional, false if not present) 182 | -------------------------------------------------------------------------------- /bacon.toml: -------------------------------------------------------------------------------- 1 | # This is a configuration file for the bacon tool 2 | # More info at https://github.com/Canop/bacon 3 | 4 | default_job = "check-all" 5 | 6 | [jobs] 7 | 8 | [jobs.check] 9 | command = ["cargo", "check", "--color", "always"] 10 | need_stdout = false 11 | 12 | [jobs.check-all] 13 | command = [ 14 | "cargo", "check", 15 | "--all-targets", 16 | "--color", "always", 17 | "--features", "clipboard", 18 | ] 19 | need_stdout = false 20 | watch = ["tests", "benches", "examples"] 21 | 22 | [jobs.clippy] 23 | command = [ 24 | "cargo", "clippy", 25 | "--color", "always", 26 | "--", 27 | "-A", "clippy::bool_to_int_with_if", 28 | "-A", "clippy::collapsible_else_if", 29 | "-A", "clippy::collapsible_if", 30 | "-A", "clippy::manual_clamp", 31 | "-A", "clippy::match_like_matches_macro", 32 | "-A", "clippy::module_inception", 33 | ] 34 | need_stdout = false 35 | 36 | [jobs.test] 37 | command = ["cargo", "test", "--color", "always"] 38 | need_stdout = true 39 | watch = ["tests"] 40 | 41 | [jobs.doc] 42 | command = ["cargo", "doc", "--color", "always", "--no-deps"] 43 | need_stdout = false 44 | 45 | [keybindings] 46 | c = "job:clippy" 47 | -------------------------------------------------------------------------------- /build.sh: -------------------------------------------------------------------------------- 1 | # This script compiles safecloset for the local system 2 | # 3 | # After compilation, safecloset can be found in target/release 4 | # 5 | # If you're not a developer but just want to install safecloset to use it, 6 | # you'll probably prefer one of the options listed at 7 | # https://dystroy.org/safecloset/install 8 | # 9 | # The line below can be safely executed on systems which don't 10 | # support sh scripts. 11 | 12 | cargo build --release --features "clipboard" 13 | 14 | # If the line above didn't work, you may use this one which won't 15 | # have the "clipboard" feature: 16 | # 17 | # cargo build --release --locked 18 | -------------------------------------------------------------------------------- /compile-all-targets.sh: -------------------------------------------------------------------------------- 1 | # WARNING: This script is NOT meant for normal installation, it's dedicated 2 | # to the compilation of all supported targets, from a linux machine. 3 | # This is a long process and it involves specialized toolchains. 4 | # For usual compilation do 5 | # cargo build --release 6 | 7 | H1="\n\e[30;104;1m\e[2K\n\e[A" # style first header 8 | H2="\n\e[30;104m\e[1K\n\e[A" # style second header 9 | EH="\e[00m\n\e[2K" # end header 10 | NAME=safecloset 11 | version=$(./version.sh) 12 | 13 | echo -e "${H1}Compilation of all targets for $NAME $version${EH}" 14 | 15 | # clean previous build 16 | rm -rf build 17 | mkdir build 18 | echo " build cleaned" 19 | 20 | # Build versions for other platforms using cargo cross 21 | cross_build() { 22 | target_name="$1" 23 | target="$2" 24 | features="$3" 25 | echo -e "${H2}Compiling the $target_name version (target=$target, features='$features')${EH}" 26 | cargo clean 27 | if [[ -n $features ]] 28 | then 29 | cross build --target "$target" --release --features "$features" 30 | else 31 | cross build --target "$target" --release 32 | fi 33 | mkdir "build/$target" 34 | if [[ $target_name == 'Windows' ]] 35 | then 36 | exec="$NAME.exe" 37 | else 38 | exec="$NAME" 39 | fi 40 | cp "target/$target/release/$exec" "build/$target/" 41 | } 42 | 43 | # cross_build "x86-64 GLIBC" "x86_64-unknown-linux-gnu" "" 44 | # cross_build "MUSL" "x86_64-unknown-linux-musl" "" 45 | # cross_build "ARM 32" "armv7-unknown-linux-gnueabihf" "" 46 | # cross_build "ARM 32 MUSL" "armv7-unknown-linux-musleabi" "" 47 | # cross_build "ARM 64" "aarch64-unknown-linux-gnu" "" 48 | # cross_build "ARM 64 MUSL" "aarch64-unknown-linux-musl" "" 49 | cross_build "Windows" "x86_64-pc-windows-gnu" "clipboard" 50 | cross_build "Android" "aarch64-linux-android" "clipboard" 51 | 52 | # Build the default X86_64 linux version (with clipboard support, needing a recent GLIBC) 53 | # recent glibc 54 | echo -e "${H2}Compiling the standard linux version${EH}" 55 | cargo build --release --features "clipboard" 56 | strip "target/release/$NAME" 57 | mkdir build/x86_64-linux/ 58 | cp "target/release/$NAME" build/x86_64-linux/ 59 | 60 | # add a summary of content 61 | echo ' 62 | This archive contains pre-compiled binaries. 63 | 64 | For more information, or if you prefer to compile yourself, see https://dystroy.org/safecloset/install 65 | ' > build/install.md 66 | 67 | echo -e "${H1}FINISHED${EH}" 68 | -------------------------------------------------------------------------------- /doc/clear-drawer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/clear-drawer.png -------------------------------------------------------------------------------- /doc/drawer-creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/drawer-creation.png -------------------------------------------------------------------------------- /doc/drawer-opening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/drawer-opening.png -------------------------------------------------------------------------------- /doc/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/help.png -------------------------------------------------------------------------------- /doc/multiline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/multiline.png -------------------------------------------------------------------------------- /doc/new-closet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/new-closet.png -------------------------------------------------------------------------------- /doc/new-drawer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/new-drawer.png -------------------------------------------------------------------------------- /doc/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/search.png -------------------------------------------------------------------------------- /doc/typing-entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/doc/typing-entry.png -------------------------------------------------------------------------------- /fmt.sh: -------------------------------------------------------------------------------- 1 | cargo +nightly fmt 2 | -------------------------------------------------------------------------------- /release.sh: -------------------------------------------------------------------------------- 1 | # build a new release of safecloset 2 | # This isn't used for normal compilation but for the building of the official releases 3 | version=$(sed 's/version = "\([0-9.]\{1,\}\)"/\1/;t;d' Cargo.toml | head -1) 4 | 5 | echo "Building release $version" 6 | 7 | # make the build directory and compile for all targets 8 | ./compile-all-targets.sh 9 | 10 | # add the readme and changelog in the build directory 11 | echo "This is safecloset. More info and installation instructions on https://github.com/Canop/safecloset" > build/README.md 12 | cp CHANGELOG.md build 13 | 14 | # publish version number 15 | echo "$version" > build/version 16 | 17 | # prepare the release archive 18 | rm safecloset_*.zip 19 | zip -r "safecloset_$version.zip" build/* 20 | 21 | # copy it to releases folder 22 | mkdir releases 23 | cp "safecloset_$version.zip" releases 24 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | edition = "2021" 2 | style_edition = "2024" 3 | imports_granularity = "one" 4 | imports_layout = "Vertical" 5 | fn_params_layout = "Vertical" 6 | -------------------------------------------------------------------------------- /src/cli/args.rs: -------------------------------------------------------------------------------- 1 | use { 2 | argh::FromArgs, 3 | std::path::PathBuf, 4 | }; 5 | 6 | #[derive(Debug, FromArgs)] 7 | /// SafeCloset keeps your secrets - 8 | /// Documentation and source code at https://dystroy.org/safecloset 9 | pub struct Args { 10 | /// print the version 11 | #[argh(switch, short = 'v')] 12 | pub version: bool, 13 | 14 | /// hide unselected values 15 | #[argh(switch, short = 'h')] 16 | pub hide: bool, 17 | 18 | /// immediately prompt for a password to open a drawer 19 | #[argh(switch, short = 'o')] 20 | pub open: bool, 21 | 22 | #[argh(positional)] 23 | /// the closet file to open or create 24 | pub path: Option, 25 | } 26 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | mod args; 2 | 3 | pub use args::Args; 4 | 5 | use crate::{ 6 | core::OpenCloset, 7 | error::SafeClosetError, 8 | tui, 9 | }; 10 | 11 | /// run the command line application. 12 | /// 13 | /// Starts the TUI if a path to a closet is given 14 | pub fn run() -> Result<(), SafeClosetError> { 15 | let args: Args = argh::from_env(); 16 | if args.version { 17 | println!("SafeCloset {}", env!("CARGO_PKG_VERSION"),); 18 | return Ok(()); 19 | } 20 | info!("args: {:#?}", &args); 21 | 22 | if let Some(path) = &args.path { 23 | let closet = OpenCloset::open_or_create(path.clone())?; 24 | tui::run(closet, &args)?; 25 | } else { 26 | println!( 27 | "Please provide as argument the path to the closet file to create or open, \ 28 | or use --help for help." 29 | ); 30 | } 31 | 32 | Ok(()) 33 | } 34 | -------------------------------------------------------------------------------- /src/core/closed_drawer.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | aes_gcm_siv::{ 4 | Nonce, 5 | aead::Aead, 6 | }, 7 | serde::{ 8 | Deserialize, 9 | Serialize, 10 | }, 11 | }; 12 | 13 | /// a closed, crypted, drawer 14 | #[derive(Serialize, Deserialize)] 15 | pub struct ClosedDrawer { 16 | id: DrawerId, 17 | 18 | nonce: Box<[u8]>, 19 | 20 | /// crypted serialized DrawerContent 21 | content: Box<[u8]>, 22 | } 23 | 24 | impl Identified for ClosedDrawer { 25 | fn get_id(&self) -> &DrawerId { 26 | &self.id 27 | } 28 | } 29 | 30 | impl ClosedDrawer { 31 | pub fn new( 32 | id: DrawerId, 33 | nonce: Box<[u8]>, 34 | content: Box<[u8]>, 35 | ) -> Self { 36 | Self { id, nonce, content } 37 | } 38 | 39 | /// Try to decrypt the content with the provided password 40 | /// and the closet's salt, then return the open drawer with 41 | /// clear data and the password to allow reencrypting. 42 | /// 43 | /// This function can also be used to check drawer existence. 44 | pub fn open( 45 | &self, 46 | depth: usize, 47 | password: String, 48 | closet: &Closet, 49 | ) -> Result { 50 | let cipher = closet.cipher(&password)?; 51 | let nonce = Nonce::from_slice(&self.nonce); 52 | let clear_content = cipher 53 | .decrypt(nonce, self.content.as_ref()) 54 | .map_err(|_| CoreError::Aead)?; 55 | let content: DrawerContent = rmp_serde::from_read(&*clear_content)?; 56 | if content.id != self.id { 57 | Err(CoreError::UnconsistentData) 58 | } else { 59 | Ok(OpenDrawer { 60 | depth, 61 | password, 62 | content, 63 | }) 64 | } 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/core/closet.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | aes_gcm_siv::{ 4 | Aes256GcmSiv, 5 | Key, 6 | KeyInit, 7 | }, 8 | rand::{ 9 | Rng, 10 | rng, 11 | seq::SliceRandom, 12 | }, 13 | serde::{ 14 | Deserialize, 15 | Serialize, 16 | }, 17 | std::{ 18 | fs, 19 | path::Path, 20 | }, 21 | }; 22 | 23 | /// The closet containing all the crypted drawers 24 | #[derive(Serialize, Deserialize)] 25 | pub struct Closet { 26 | /// Clear comments, which can be read with a standard binary/hex editor 27 | #[serde(default = "default_clear_comments")] 28 | pub comments: String, 29 | 30 | /// The salt used to generate the cipher keys from the passwords 31 | pub salt: String, 32 | 33 | /// The crypted drawers 34 | pub drawers: Vec, 35 | } 36 | 37 | /// Return the default comments which are written in the clear 38 | /// part of the file. The rationale for writing the name of the 39 | /// soft is that you may forget it and need to find the software 40 | /// to open your closet file, while attackers are the ones who 41 | /// may the most easily guess it. 42 | fn default_clear_comments() -> String { 43 | "Closet file written with SafeCloset\nhttps://dystroy.org/safecloset".to_string() 44 | } 45 | 46 | /// compute the number of decoy drawers we must create for 47 | /// the given depth 48 | fn random_decoy_drawers_count(depth: usize) -> usize { 49 | let mut n = match depth { 50 | 0 => rng().random_range(3..6), 51 | 1 => rng().random_range(1..3), 52 | 2 => rng().random_range(0..2), 53 | _ => 0, 54 | }; 55 | while rng().random_bool(0.2) { 56 | n += 1; 57 | } 58 | n 59 | } 60 | 61 | impl Closet { 62 | pub fn new(depth: usize) -> Result { 63 | let comments = default_clear_comments(); 64 | let salt = random_password(); 65 | let drawers = Vec::new(); 66 | let mut closet = Self { 67 | comments, 68 | salt, 69 | drawers, 70 | }; 71 | // creating decoy drawers 72 | for _ in 0..random_decoy_drawers_count(depth) { 73 | closet.create_drawer_unchecked(depth, random_password())?; 74 | } 75 | Ok(closet) 76 | } 77 | 78 | /// Save the closet to a file 79 | pub fn save( 80 | &self, 81 | path: &Path, 82 | ) -> Result<(), CoreError> { 83 | if path.exists() { 84 | let backup_path = path.with_extension("old"); 85 | if backup_path.exists() { 86 | fs::remove_file(&backup_path)?; 87 | } 88 | fs::rename(path, &backup_path)?; 89 | } 90 | self.write_to_file(path)?; 91 | Ok(()) 92 | } 93 | 94 | pub fn write_to_file( 95 | &self, 96 | path: &Path, 97 | ) -> Result<(), CoreError> { 98 | if path.exists() { 99 | return Err(CoreError::FileExists(path.to_path_buf())); 100 | } 101 | let mut file = fs::File::create(path)?; 102 | rmp_serde::encode::write_named(&mut file, &self)?; 103 | Ok(()) 104 | } 105 | 106 | /// read a closet from a file 107 | pub fn from_file(path: &Path) -> Result { 108 | let file = fs::File::open(path)?; 109 | let closet = rmp_serde::decode::from_read(file)?; 110 | Ok(closet) 111 | } 112 | 113 | /// Create a drawer without checking first the password isn't used by 114 | /// another drawer, or that the password meets minimal requirements, 115 | /// add it to the closed drawers of the closet. 116 | /// 117 | /// This is fast but dangerous, and should not be used on user action. 118 | fn create_drawer_unchecked( 119 | &mut self, 120 | depth: usize, 121 | password: String, 122 | ) -> Result { 123 | let drawer_content = DrawerContent::new(depth)?; 124 | let mut open_drawer = OpenDrawer::new(depth, password, drawer_content); 125 | let closed_drawer = open_drawer.close(self)?; 126 | self.drawers.push(closed_drawer); 127 | Ok(open_drawer) 128 | } 129 | 130 | /// Create a drawer, add it to the closet. 131 | /// 132 | /// Return an error if the password is already used by 133 | /// another drawer (which probably means the user wanted 134 | /// to open a drawer and not create one). 135 | pub fn create_drawer( 136 | &mut self, 137 | depth: usize, 138 | password: String, 139 | ) -> Result { 140 | if password.len() < MIN_PASSWORD_LENGTH { 141 | return Err(CoreError::PasswordTooShort); 142 | } 143 | if self.is_password_taken(depth, &password) { 144 | return Err(CoreError::PasswordAlreadyUsed); 145 | } 146 | self.create_drawer_unchecked(depth, password) 147 | } 148 | 149 | /// Open the drawer responding to this password and return it. 150 | /// 151 | /// Return None when no drawer can be opened with this password. 152 | pub fn open_drawer( 153 | &self, 154 | depth: usize, 155 | password: &str, 156 | ) -> Option { 157 | for closed_drawer in &self.drawers { 158 | let open_drawer = time!( 159 | "closed_drawer.open", 160 | closed_drawer.open(depth, password.to_string(), self,) 161 | ); 162 | if let Ok(open_drawer) = open_drawer { 163 | return Some(open_drawer); 164 | } 165 | } 166 | None 167 | } 168 | 169 | pub fn is_password_taken( 170 | &self, 171 | depth: usize, 172 | password: &str, 173 | ) -> bool { 174 | self.open_drawer(depth, password).is_some() 175 | } 176 | 177 | /// Close the passed drawer, put it back among closed ones 178 | pub fn close_drawer( 179 | &mut self, 180 | mut open_drawer: OpenDrawer, 181 | ) -> Result { 182 | let closed_drawer = open_drawer.close(self)?; 183 | Ok(self.push_drawer_back(closed_drawer)) 184 | } 185 | 186 | fn push_drawer_back( 187 | &mut self, 188 | drawer: ClosedDrawer, 189 | ) -> bool { 190 | for idx in 0..self.drawers.len() { 191 | if self.drawers[idx].has_same_id(&drawer) { 192 | self.drawers[idx] = drawer; 193 | return true; 194 | } 195 | } 196 | false 197 | } 198 | 199 | /// Change the order of drawers 200 | pub fn shuffle_drawers(&mut self) { 201 | self.drawers.shuffle(&mut rng()); 202 | } 203 | 204 | /// Close the drawer then reopen it. 205 | /// 206 | /// After this operation, the closet contains the content of the given 207 | /// drawer but the closet isn't saved. 208 | #[allow(dead_code)] 209 | pub fn close_then_reopen( 210 | &mut self, 211 | drawer: OpenDrawer, 212 | ) -> Result { 213 | let depth = drawer.depth; 214 | let password = drawer.password.clone(); 215 | self.close_drawer(drawer)?; 216 | self.open_drawer(depth, &password).ok_or_else(|| { 217 | // shouldn't happen 218 | CoreError::InternalError("can't reopen just closed drawer".to_string()) 219 | }) 220 | } 221 | 222 | pub fn cipher( 223 | &self, 224 | password: &str, 225 | ) -> Result { 226 | let config = argon2::Config { 227 | hash_length: 32, 228 | ..Default::default() 229 | }; 230 | //config.variant = argon2::Variant::Argon2i; 231 | //config.version = argon2::Version::Version13; 232 | let hash = argon2::hash_raw(password.as_bytes(), self.salt.as_bytes(), &config)?; 233 | let key = Key::::from_slice(&hash); 234 | Ok(Aes256GcmSiv::new(key)) 235 | } 236 | } 237 | -------------------------------------------------------------------------------- /src/core/core_error.rs: -------------------------------------------------------------------------------- 1 | /// Core error type 2 | #[derive(thiserror::Error, Debug)] 3 | pub enum CoreError { 4 | #[error("IO error: {0}")] 5 | IO(#[from] std::io::Error), 6 | 7 | #[error("Passphrase too short")] 8 | PasswordTooShort, 9 | 10 | #[error("MessagePack Encode error: {0}")] 11 | MessagePackEncode(#[from] rmp_serde::encode::Error), 12 | 13 | #[error("MessagePack Decode error: {0}")] 14 | MessagePackDecode(#[from] rmp_serde::decode::Error), 15 | 16 | #[error("AEAD error")] 17 | Aead, // The AEAD error type is opaque 18 | 19 | #[error("File {0} already exists")] 20 | FileExists(std::path::PathBuf), 21 | 22 | #[error("Argon2 password hash error: {0}")] 23 | Argon2(#[from] argon2::Error), 24 | 25 | #[error("Unconsistent data")] 26 | UnconsistentData, 27 | 28 | #[error("Internal error: {0}")] 29 | InternalError(String), 30 | 31 | #[error("Passphrase already used for an existing drawer")] 32 | PasswordAlreadyUsed, 33 | 34 | #[error("No open drawer")] 35 | NoOpenDrawer, 36 | 37 | #[error("Invalid Push Back")] 38 | InvalidPushBack, 39 | 40 | #[error("Invalid Delete")] 41 | InvalidDelete, 42 | 43 | #[error("Operation only permitted at max depth")] 44 | OperationOnlyPermittedAtMaxDepth, 45 | } 46 | -------------------------------------------------------------------------------- /src/core/drawer_content.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | }; 8 | 9 | /// What's inside a drawer 10 | #[derive(Serialize, Deserialize)] 11 | pub struct DrawerContent { 12 | pub id: DrawerId, 13 | 14 | /// the entries of this depth 15 | pub entries: Vec, 16 | 17 | /// user settings related to that drawer 18 | pub settings: DrawerSettings, 19 | 20 | /// the crypted sub-drawers 21 | pub closet: Closet, 22 | 23 | /// some random bytes, rewritten before every save 24 | garbage: Box<[u8]>, 25 | } 26 | 27 | impl Identified for DrawerContent { 28 | fn get_id(&self) -> &DrawerId { 29 | &self.id 30 | } 31 | } 32 | 33 | impl DrawerContent { 34 | pub fn new(depth: usize) -> Result { 35 | let id = DrawerId::new(); 36 | let entries = Vec::new(); 37 | let settings = DrawerSettings::default(); 38 | let closet = Closet::new(depth + 1)?; 39 | let garbage = Vec::new().into(); // will be (re)filled for save 40 | Ok(Self { 41 | id, 42 | entries, 43 | settings, 44 | closet, 45 | garbage, 46 | }) 47 | } 48 | 49 | /// Return the index of the first empty entry, creating 50 | /// it if necessary 51 | pub fn empty_entry(&mut self) -> usize { 52 | for (idx, entry) in self.entries.iter().enumerate() { 53 | if entry.is_empty() { 54 | return idx; 55 | } 56 | } 57 | self.entries.push(Entry::default()); 58 | self.entries.len() - 1 59 | } 60 | 61 | /// Insert a new entry at the given index or before and 62 | /// return the index of the new entry 63 | #[allow(dead_code)] 64 | pub fn insert_before( 65 | &mut self, 66 | idx: usize, 67 | ) -> usize { 68 | let idx = if self.entries.is_empty() { 69 | 0 70 | } else { 71 | idx.min(self.entries.len() - 1) 72 | }; 73 | self.entries.insert(idx, Entry::default()); 74 | idx 75 | } 76 | 77 | /// Insert a new entry after the new entry if possible. 78 | /// 79 | /// Return the index of the new entry. 80 | pub fn insert_after( 81 | &mut self, 82 | idx: Option, 83 | ) -> usize { 84 | let idx = match (idx, self.entries.is_empty()) { 85 | (Some(idx), false) => (idx + 1).min(self.entries.len()), 86 | _ => 0, 87 | }; 88 | self.entries.insert(idx, Entry::default()); 89 | idx 90 | } 91 | 92 | /// Shuffle the drawers (thus ensuring the last created one 93 | /// isn't at the end), add some random bytes which makes the 94 | /// content's size undetectable 95 | pub fn add_noise(&mut self) { 96 | self.garbage = random_bytes_random_size(5..5000); 97 | self.closet.shuffle_drawers(); 98 | } 99 | 100 | /// Remove entries with both name and value empty 101 | pub fn remove_empty_entries(&mut self) { 102 | self.entries.retain(|e| !e.is_empty()); 103 | } 104 | } 105 | -------------------------------------------------------------------------------- /src/core/drawer_id.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | serde::{ 4 | Deserialize, 5 | Serialize, 6 | }, 7 | }; 8 | 9 | #[derive(Clone, PartialEq, Eq, Serialize, Deserialize)] 10 | pub struct DrawerId { 11 | bytes: Box<[u8]>, 12 | } 13 | 14 | impl DrawerId { 15 | pub fn new() -> Self { 16 | DrawerId { 17 | bytes: random_bytes(20), 18 | } 19 | } 20 | } 21 | 22 | pub trait Identified { 23 | fn get_id(&self) -> &DrawerId; 24 | 25 | fn has_same_id( 26 | &self, 27 | other: &I, 28 | ) -> bool { 29 | self.get_id() == other.get_id() 30 | } 31 | } 32 | -------------------------------------------------------------------------------- /src/core/drawer_settings.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | Deserialize, 3 | Serialize, 4 | }; 5 | 6 | /// settings of a drawer, saved in the drawer 7 | #[derive(Serialize, Deserialize, Default)] 8 | pub struct DrawerSettings { 9 | /// whether to hide unselected entry values 10 | pub hide_values: bool, 11 | /// whether to show the whole content of all values 12 | #[serde(default)] 13 | pub open_all_values: bool, 14 | /// whether to show values as markdown 15 | #[serde(default)] 16 | pub values_as_markdown: bool, 17 | } 18 | -------------------------------------------------------------------------------- /src/core/entry.rs: -------------------------------------------------------------------------------- 1 | use serde::{ 2 | Deserialize, 3 | Serialize, 4 | }; 5 | 6 | /// one of the socks in the drawer 7 | #[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Default)] 8 | pub struct Entry { 9 | pub name: String, 10 | pub value: String, 11 | } 12 | 13 | impl Entry { 14 | #[allow(dead_code)] 15 | pub fn new, V: Into>( 16 | name: N, 17 | value: V, 18 | ) -> Self { 19 | Self { 20 | name: name.into(), 21 | value: value.into(), 22 | } 23 | } 24 | pub fn is_empty(&self) -> bool { 25 | self.name.is_empty() && self.value.is_empty() 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/core/mod.rs: -------------------------------------------------------------------------------- 1 | mod closed_drawer; 2 | mod closet; 3 | mod core_error; 4 | mod drawer_content; 5 | mod drawer_id; 6 | mod drawer_settings; 7 | mod entry; 8 | mod open_closet; 9 | mod open_drawer; 10 | mod random; 11 | 12 | pub use { 13 | closed_drawer::*, 14 | closet::*, 15 | core_error::*, 16 | drawer_content::*, 17 | drawer_id::*, 18 | drawer_settings::*, 19 | entry::*, 20 | open_closet::*, 21 | open_drawer::*, 22 | random::*, 23 | }; 24 | 25 | pub const MIN_PASSWORD_LENGTH: usize = 2; 26 | 27 | /// test most opening, saving, reopening, etc. operations in 28 | /// a complex scenario 29 | #[test] 30 | fn test_create_write_read() { 31 | let pwd1 = "some test password (not a hard one but it's a test)"; 32 | let pwd2 = "请教别人一次是五分钟的傻子,从不请教别人是一辈子的傻子。"; 33 | let pwd3 = "les sanglots lents et violents de l'autre âne"; 34 | let entry1 = Entry::new("some key", "some value"); 35 | let entry2 = Entry::new("key2", "some value"); 36 | let entry2b = Entry::new("another key", "v2.2"); 37 | let entry2c = Entry::new("key 2c", "v2.3"); 38 | let entry3 = Entry::new("key3", "some other value"); 39 | let entry3b = Entry::new("hey", "what's here?"); 40 | 41 | // create a temp directory in which to run our tests 42 | let temp_dir = tempfile::tempdir().unwrap(); 43 | 44 | // define a path for our closet 45 | let path = temp_dir.path().join("test-create-write-read.safe-closet"); 46 | 47 | // create a closet on this path 48 | let mut open_closet = OpenCloset::create(path.to_path_buf()).unwrap(); 49 | 50 | // check that there are already several drawers 51 | assert!(open_closet.root_drawers_count() >= 3); 52 | 53 | // create 2 drawers at the same level 54 | open_closet.create_drawer(pwd1).unwrap(); 55 | open_closet.close_deepest_drawer().unwrap(); 56 | let drawer2 = open_closet.create_drawer(pwd2).unwrap(); 57 | drawer2.content.entries.push(entry2.clone()); 58 | 59 | // check drawer2 already contains at least a decoy drawer 60 | assert!(!drawer2.content.closet.drawers.is_empty()); 61 | 62 | // close the drawer2 63 | open_closet.close_deepest_drawer().unwrap(); 64 | 65 | // check we can't reuse a password (from a given node) 66 | assert!(matches!( 67 | open_closet.create_drawer(pwd1), 68 | Err(CoreError::PasswordAlreadyUsed), 69 | )); 70 | 71 | // reopen the first drawer and add an entry 72 | let open_drawer = open_closet.open_drawer(pwd1).unwrap(); 73 | open_drawer.content.entries.push(entry1.clone()); 74 | 75 | // save the closet 76 | open_closet.close_and_save().unwrap(); 77 | 78 | // reopen the closet 79 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap(); 80 | 81 | // open the first drawer, check our entry is here 82 | let open_drawer = open_closet.open_drawer(pwd1).unwrap(); 83 | assert_eq!(open_drawer.content.entries, vec![entry1.clone()]); 84 | 85 | // open the second drawer, check there's our entry2 86 | // (we don't close the first one before: we want to let 87 | // safecloset find it alone) 88 | let drawer2 = open_closet.open_drawer(pwd2).unwrap(); 89 | assert_eq!(drawer2.content.entries, vec![entry2.clone()]); 90 | assert_eq!(drawer2.depth, 0); 91 | 92 | // now, let's create a deep drawer inside the second drawer 93 | let drawer3 = open_closet.create_drawer(pwd3).unwrap(); 94 | drawer3.content.entries.push(entry3.clone()); 95 | assert_eq!(drawer3.depth, 1); 96 | 97 | // let's save and close everything 98 | open_closet.close_and_save().unwrap(); 99 | 100 | // reopen the closet 101 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap(); 102 | 103 | // check we can't open drawer3 from the root level 104 | assert!(open_closet.open_drawer(pwd3).is_none()); 105 | 106 | // open drawer2 then drawer3 from drawer2 107 | open_closet.open_drawer(pwd2).unwrap(); 108 | let drawer3 = open_closet.open_drawer(pwd3).unwrap(); 109 | 110 | // check its content 111 | assert_eq!(drawer3.depth, 1); 112 | assert_eq!(drawer3.content.entries, vec![entry3.clone()]); 113 | 114 | // check we can save without closing 115 | let drawer3 = open_closet.save_then_reopen().unwrap().unwrap(); 116 | assert_eq!(drawer3.depth, 1); 117 | assert_eq!(drawer3.content.entries, vec![entry3.clone()]); 118 | 119 | // add some content in d3, we'll check again later it's ok 120 | drawer3.content.entries.push(entry3b.clone()); 121 | 122 | // close drawer3, we should be beck in the enclosing drawer, d2 123 | open_closet.close_deepest_drawer().unwrap(); 124 | let drawer2 = open_closet.deepest_open_drawer().unwrap(); 125 | assert_eq!(drawer2.content.entries, vec![entry2.clone()]); 126 | drawer2.content.entries.push(entry2b.clone()); 127 | 128 | // now let's make a change but by taking the drawer instead of 129 | // just having a reference 130 | let mut drawer2 = open_closet.take_deepest_open_drawer().unwrap(); 131 | drawer2.content.entries.push(entry2c.clone()); 132 | open_closet.push_back(drawer2).unwrap(); 133 | 134 | // let's save and close everything again to check the content is right 135 | open_closet.close_and_save().unwrap(); 136 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap(); 137 | let drawer1 = open_closet.open_drawer(pwd1).unwrap(); 138 | assert_eq!(drawer1.content.entries, vec![entry1.clone()]); 139 | let drawer2 = open_closet.open_drawer(pwd2).unwrap(); 140 | assert_eq!( 141 | drawer2.content.entries, 142 | vec![entry2.clone(), entry2b.clone(), entry2c.clone()], 143 | ); 144 | let drawer3 = open_closet.open_drawer(pwd3).unwrap(); 145 | assert_eq!( 146 | drawer3.content.entries, 147 | vec![entry3.clone(), entry3b.clone()] 148 | ); 149 | 150 | // clean the temporary dir 151 | temp_dir.close().unwrap(); 152 | } 153 | 154 | /// test changing the password 155 | #[test] 156 | fn test_password_change() { 157 | let pwd1 = "*p*w*d*1"; 158 | let pwd2 = "PWD2"; 159 | let pwd3 = "p-w-d-3"; 160 | let entry1 = Entry::new("some key", "some value"); 161 | let entry2 = Entry::new("key2", "some value"); 162 | 163 | // create a temp directory in which to run our tests 164 | let temp_dir = tempfile::tempdir().unwrap(); 165 | 166 | // define a path for our closet 167 | let path = temp_dir.path().join("test-pwd-change.closet"); 168 | 169 | // create a closet on this path 170 | let mut open_closet = OpenCloset::create(path.to_path_buf()).unwrap(); 171 | 172 | // check that there are already several drawers 173 | assert!(open_closet.root_drawers_count() >= 3); 174 | 175 | // create 2 drawers at the same level, with some content 176 | let drawer1 = open_closet.create_drawer(pwd1).unwrap(); 177 | drawer1.content.entries.push(entry1.clone()); 178 | open_closet.close_deepest_drawer().unwrap(); 179 | let drawer2 = open_closet.create_drawer(pwd2).unwrap(); 180 | drawer2.content.entries.push(entry2.clone()); 181 | 182 | // save the closet 183 | open_closet.close_and_save().unwrap(); 184 | 185 | // reopen the closet 186 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap(); 187 | 188 | // open the first drawer, check our entry is here 189 | open_closet.open_drawer(pwd1).unwrap(); 190 | let mut open_drawer = open_closet.take_deepest_open_drawer().unwrap(); 191 | assert_eq!(open_drawer.content.entries, vec![entry1.clone()]); 192 | 193 | // check we can't change the password to an already used one 194 | assert!(open_closet.change_password(&mut open_drawer, pwd1).is_err()); 195 | 196 | // change the password 197 | open_closet.change_password(&mut open_drawer, pwd3).unwrap(); 198 | 199 | // push back the drawer so that it can be saved 200 | open_closet.push_back(open_drawer).unwrap(); 201 | 202 | // save the closet 203 | open_closet.close_and_save().unwrap(); 204 | 205 | // reopen the closet 206 | let mut open_closet = OpenCloset::open(path.to_path_buf()).unwrap(); 207 | 208 | // check we can't open with the old password 209 | assert!(open_closet.open_drawer(pwd1).is_none()); 210 | 211 | // open with the new password 212 | open_closet.open_drawer(pwd3).unwrap(); 213 | let drawer1 = open_closet.take_deepest_open_drawer().unwrap(); 214 | 215 | // check its content 216 | assert_eq!(drawer1.content.entries, vec![entry1.clone()]); 217 | 218 | // save the closet 219 | open_closet.close_and_save().unwrap(); 220 | 221 | // clean the temporary dir 222 | temp_dir.close().unwrap(); 223 | } 224 | -------------------------------------------------------------------------------- /src/core/open_closet.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | std::path::{ 4 | Path, 5 | PathBuf, 6 | }, 7 | }; 8 | 9 | /// The root closet, when open 10 | pub struct OpenCloset { 11 | /// the path in which the closet is persisted 12 | path: PathBuf, 13 | 14 | /// the deserialized content (nothing uncrypted here) 15 | root_closet: Closet, 16 | 17 | // drawers, indexed by depth 18 | open_drawers: Vec, 19 | 20 | // the closet was just created because there no preexisting file 21 | created: bool, 22 | } 23 | 24 | impl OpenCloset { 25 | /// Either create a new closet, or open an existing one, depending 26 | /// on whether the file exists 27 | pub fn open_or_create>(path: P) -> Result { 28 | let path = path.into(); 29 | if path.exists() { 30 | Self::open(path) 31 | } else { 32 | Self::create(path) 33 | } 34 | } 35 | 36 | pub fn root_closet(&mut self) -> &mut Closet { 37 | &mut self.root_closet 38 | } 39 | 40 | pub fn just_created(&self) -> bool { 41 | self.created 42 | } 43 | 44 | #[cfg(test)] 45 | pub fn root_drawers_count(&self) -> usize { 46 | self.root_closet.drawers.len() 47 | } 48 | 49 | /// Create a new closet, with a random number of drawers 50 | /// (which won't be openable as you won't have their password) 51 | pub fn create(path: PathBuf) -> Result { 52 | if path.exists() { 53 | return Err(CoreError::FileExists(path)); 54 | } 55 | let open_closet = OpenCloset { 56 | path, 57 | root_closet: Closet::new(0)?, 58 | open_drawers: Vec::new(), 59 | created: true, 60 | }; 61 | Ok(open_closet) 62 | } 63 | 64 | /// Open a closet from a closet file 65 | pub fn open(path: PathBuf) -> Result { 66 | let root_closet = Closet::from_file(&path)?; 67 | let open_closet = OpenCloset { 68 | path, 69 | root_closet, 70 | open_drawers: Vec::new(), 71 | created: false, 72 | }; 73 | Ok(open_closet) 74 | } 75 | 76 | /// Save all the open drawers, then the closet in its file. 77 | pub fn close_and_save(&mut self) -> Result<(), CoreError> { 78 | while !self.open_drawers.is_empty() { 79 | self.close_deepest_drawer()?; 80 | } 81 | self.root_closet.save(&self.path) 82 | } 83 | 84 | /// Save all the open drawers, then the closet in its file, 85 | /// then reopen the drawer which was the deepest one before 86 | /// saving. 87 | /// 88 | /// If nothing was open, nothing is reopened. 89 | pub fn save_then_reopen(&mut self) -> Result, CoreError> { 90 | let mut passwords = Vec::new(); 91 | while !self.open_drawers.is_empty() { 92 | passwords.push(self.close_deepest_drawer()?); 93 | } 94 | self.root_closet.save(&self.path)?; 95 | // now we reopen 96 | while let Some(password) = passwords.pop() { 97 | if !self.open_drawer_at_depth(self.depth(), &password) { 98 | return Err(CoreError::InternalError( 99 | "drawer can't be reopened".to_string(), 100 | )); 101 | } 102 | } 103 | Ok(self.open_drawers.last_mut()) 104 | } 105 | 106 | /// Save all the open drawers, then the closet in its file, 107 | /// then reopen the drawer which was the deepest one before 108 | /// saving. 109 | pub fn push_back_save_retake( 110 | &mut self, 111 | open_drawer: OpenDrawer, 112 | ) -> Result { 113 | self.push_back(open_drawer)?; 114 | self.save_then_reopen()?; 115 | Ok(self.take_deepest_open_drawer().unwrap()) // SAFETY: we just pushed back, so there's a drawer 116 | } 117 | 118 | /// Return the path to the closet file 119 | pub fn path(&self) -> &Path { 120 | &self.path 121 | } 122 | 123 | /// Give the number of open drawers, which is the depth 124 | pub fn depth(&self) -> usize { 125 | self.open_drawers.len() 126 | } 127 | 128 | /// Open the drawer at the given depth, and return true on success 129 | /// 130 | /// Do nothing if (depth, password) don't match an existing drawer 131 | fn open_drawer_at_depth( 132 | &mut self, 133 | depth: usize, 134 | password: &str, 135 | ) -> bool { 136 | if depth > self.open_drawers.len() { 137 | warn!("invalid depth for drawer opening"); 138 | return false; 139 | } 140 | let closet = if depth == 0 { 141 | &mut self.root_closet 142 | } else { 143 | &mut self.open_drawers[depth - 1].content.closet 144 | }; 145 | if let Some(open_drawer) = closet.open_drawer(depth, password) { 146 | self.open_drawers.truncate(depth); 147 | self.open_drawers.push(open_drawer); 148 | true 149 | } else { 150 | false 151 | } 152 | } 153 | 154 | /// Try to open a drawer at any depth 155 | /// (preferably from one of the deepest open drawers) 156 | pub fn open_drawer( 157 | &mut self, 158 | password: &str, 159 | ) -> Option<&mut OpenDrawer> { 160 | let mut depth = self.open_drawers.len(); 161 | let mut open: bool; 162 | loop { 163 | open = self.open_drawer_at_depth(depth, password); 164 | if open || depth == 0 { 165 | break; 166 | } 167 | depth -= 1; 168 | } 169 | if open { 170 | Some(&mut self.open_drawers[depth]) 171 | } else { 172 | None 173 | } 174 | } 175 | 176 | /// Try to open a drawer at any depth (preferably from 177 | /// one of the deepest open drawers) then take it 178 | #[must_use] 179 | pub fn open_take_drawer( 180 | &mut self, 181 | password: &str, 182 | ) -> Option { 183 | if self.open_drawer(password).is_some() { 184 | self.take_deepest_open_drawer() 185 | } else { 186 | None 187 | } 188 | } 189 | 190 | /// Create a drawer at the deepest possible depth 191 | /// (to create a less deep drawer, you must close 192 | /// the deeper one(s) before) 193 | #[allow(dead_code)] 194 | pub fn create_drawer>( 195 | &mut self, 196 | password: S, 197 | ) -> Result<&mut OpenDrawer, CoreError> { 198 | let depth = self.depth(); 199 | let open_drawer = self 200 | .deepest_closet_mut() 201 | .create_drawer(depth, password.into())?; 202 | self.open_drawers.push(open_drawer); 203 | Ok(&mut self.open_drawers[depth]) 204 | } 205 | 206 | /// Create a drawer at the deepest possible depth 207 | /// (to create a less deep drawer, you should close 208 | /// the deeper one(s) before) 209 | pub fn create_take_drawer>( 210 | &mut self, 211 | password: S, 212 | ) -> Result { 213 | let depth = self.depth(); 214 | let open_drawer = self 215 | .deepest_closet_mut() 216 | .create_drawer(depth, password.into())?; 217 | Ok(open_drawer) 218 | } 219 | 220 | /// Close the deepest open drawer and return its password 221 | pub fn close_deepest_drawer(&mut self) -> Result { 222 | match self.open_drawers.pop() { 223 | Some(open_drawer) => { 224 | let password = open_drawer.password.clone(); 225 | let closet = if self.open_drawers.is_empty() { 226 | &mut self.root_closet 227 | } else { 228 | let idx = self.open_drawers.len() - 1; 229 | &mut self.open_drawers[idx].content.closet 230 | }; 231 | closet.close_drawer(open_drawer)?; 232 | Ok(password) 233 | } 234 | None => Err(CoreError::NoOpenDrawer), 235 | } 236 | } 237 | 238 | #[allow(dead_code)] 239 | pub fn deepest_open_drawer(&mut self) -> Option<&mut OpenDrawer> { 240 | self.open_drawers.last_mut() 241 | } 242 | 243 | /// take a drawer to modify it owned. Won't be saved if you 244 | /// don't push it back 245 | #[must_use] 246 | pub fn take_deepest_open_drawer(&mut self) -> Option { 247 | self.open_drawers.pop() 248 | } 249 | 250 | fn deepest_closet(&self) -> &Closet { 251 | let depth = self.open_drawers.len(); 252 | if self.open_drawers.is_empty() { 253 | &self.root_closet 254 | } else { 255 | &self.open_drawers[depth - 1].content.closet 256 | } 257 | } 258 | 259 | fn deepest_closet_mut(&mut self) -> &mut Closet { 260 | let depth = self.open_drawers.len(); 261 | if self.open_drawers.is_empty() { 262 | &mut self.root_closet 263 | } else { 264 | &mut self.open_drawers[depth - 1].content.closet 265 | } 266 | } 267 | 268 | pub fn push_back( 269 | &mut self, 270 | open_drawer: OpenDrawer, 271 | ) -> Result<(), CoreError> { 272 | let id_checked = self 273 | .deepest_closet() 274 | .drawers 275 | .iter() 276 | .any(|closed_drawer| closed_drawer.has_same_id(&open_drawer)); 277 | if id_checked { 278 | self.open_drawers.push(open_drawer); 279 | Ok(()) 280 | } else { 281 | Err(CoreError::InvalidPushBack) 282 | } 283 | } 284 | 285 | /// Delete a drawer in the in-memory closet. 286 | /// 287 | /// The operation isn't saved on disk until the closet is saved. 288 | /// 289 | /// This isn't called yet in the UI because I'm not sure it's useful 290 | /// enough to clutter the menu 291 | #[allow(dead_code)] 292 | pub fn delete_drawer( 293 | &mut self, 294 | open_drawer: OpenDrawer, 295 | ) -> Result<(), CoreError> { 296 | let closet = self.deepest_closet_mut(); 297 | for (idx, drawer) in closet.drawers.iter().enumerate() { 298 | if drawer.has_same_id(&open_drawer) { 299 | closet.drawers.remove(idx); 300 | return Ok(()); 301 | } 302 | } 303 | Err(CoreError::InvalidDelete) 304 | } 305 | 306 | /// Give a new password to the drawer. 307 | /// 308 | /// Mutate the drawer but no real change will be done until the drawer and the closet 309 | /// are saved. 310 | /// 311 | /// Fail with no change if the new password is already taken in the parent closet. 312 | pub fn change_password>( 313 | &self, 314 | open_drawer: &mut OpenDrawer, 315 | new_password: P, 316 | ) -> Result<(), CoreError> { 317 | let new_password = new_password.into(); 318 | if open_drawer.depth != self.depth() { 319 | return Err(CoreError::OperationOnlyPermittedAtMaxDepth); 320 | } 321 | if new_password.len() < MIN_PASSWORD_LENGTH { 322 | return Err(CoreError::PasswordTooShort); 323 | } 324 | if self 325 | .deepest_closet() 326 | .is_password_taken(open_drawer.depth, &new_password) 327 | { 328 | return Err(CoreError::PasswordAlreadyUsed); 329 | } 330 | open_drawer.password = new_password; 331 | Ok(()) 332 | } 333 | } 334 | -------------------------------------------------------------------------------- /src/core/open_drawer.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | aes_gcm_siv::aead::Aead, 4 | }; 5 | 6 | /// An open uncrypted drawer, with its content and the pass 7 | /// making it possible to save it on change 8 | pub struct OpenDrawer { 9 | pub depth: usize, 10 | pub(super) password: String, 11 | pub content: DrawerContent, 12 | } 13 | 14 | impl Identified for OpenDrawer { 15 | fn get_id(&self) -> &DrawerId { 16 | self.content.get_id() 17 | } 18 | } 19 | 20 | impl OpenDrawer { 21 | pub(crate) fn new( 22 | depth: usize, 23 | password: String, 24 | content: DrawerContent, 25 | ) -> Self { 26 | Self { 27 | depth, 28 | password, 29 | content, 30 | } 31 | } 32 | 33 | /// Change the drawer_content into a closed_drawer 34 | pub(crate) fn close( 35 | &mut self, 36 | closet: &Closet, 37 | ) -> Result { 38 | let cipher = closet.cipher(&self.password)?; 39 | self.content.add_noise(); 40 | let serialized_content = rmp_serde::encode::to_vec_named(&self.content)?; 41 | let nonce = random_nonce(); 42 | let crypted_content = cipher 43 | .encrypt(&nonce, &*serialized_content) 44 | .map_err(|_| CoreError::Aead)?; 45 | let nonce = nonce.as_slice().into(); 46 | let id = self.content.id.clone(); 47 | Ok(ClosedDrawer::new( 48 | id, 49 | nonce, 50 | crypted_content.into_boxed_slice(), 51 | )) 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/core/random.rs: -------------------------------------------------------------------------------- 1 | use { 2 | aes_gcm_siv::Nonce, 3 | rand::{ 4 | Rng, 5 | RngCore, 6 | rng, 7 | }, 8 | std::ops::Range, 9 | }; 10 | 11 | const PASSWORD_CHARSET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZ\ 12 | abcdefghijklmnopqrstuvwxyz\ 13 | 0123456789)(*&^%$#@!~\ 14 | ._[]{}/;:?%,=-+'"; 15 | 16 | pub fn random_bytes(count: usize) -> Box<[u8]> { 17 | let mut vec = vec![0u8; count]; 18 | rng().fill_bytes(&mut vec); 19 | vec.into_boxed_slice() 20 | } 21 | 22 | /// Generate a random length array of random bytes. 23 | /// 24 | /// min_size and max_size are both included. 25 | #[allow(dead_code)] 26 | pub fn random_bytes_random_size(range: Range) -> Box<[u8]> { 27 | random_bytes(rng().random_range(range)) 28 | } 29 | 30 | /// Generate a random nonce for AES-GCM. 31 | /// 32 | /// AES-GCM nonces are 12 bytes (96 bits) 33 | pub fn random_nonce() -> Nonce { 34 | let mut nonce = Nonce::default(); 35 | rng().fill_bytes(&mut nonce[0..12]); 36 | nonce 37 | } 38 | 39 | pub fn random_password() -> String { 40 | let mut rng = rng(); 41 | (0..rng.random_range(30..80)) 42 | .map(|_| { 43 | let idx = rng.random_range(0..PASSWORD_CHARSET.len()); 44 | PASSWORD_CHARSET[idx] as char 45 | }) 46 | .collect() 47 | } 48 | 49 | /// check we're really making different nonces 50 | #[test] 51 | pub fn test_random_nonce() { 52 | assert_ne!(random_nonce(), random_nonce()); 53 | } 54 | -------------------------------------------------------------------------------- /src/csv/mod.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::core::Entry, 3 | char_reader::CharReader, 4 | std::{ 5 | fs::File, 6 | io, 7 | path::Path, 8 | }, 9 | }; 10 | 11 | #[derive(Debug, Default)] 12 | pub struct Csv { 13 | pub rows: Vec>, 14 | } 15 | 16 | impl Csv { 17 | /// create a new CSV from the content of the given reader, which is 18 | /// consumed until EOF. 19 | /// The separator is the cell separator, usually the comma (',') 20 | pub fn new( 21 | src: R, 22 | separator: char, 23 | ) -> io::Result { 24 | let mut csv = Self::default(); 25 | csv.parse(src, separator)?; 26 | Ok(csv) 27 | } 28 | 29 | pub fn from_path( 30 | path: &Path, 31 | separator: char, 32 | ) -> io::Result { 33 | let file = File::open(path)?; 34 | Self::new(file, separator) 35 | } 36 | 37 | /// Consume the reader till EOF, adding all the 38 | /// rows found 39 | pub fn parse( 40 | &mut self, 41 | src: R, 42 | separator: char, 43 | ) -> io::Result<()> { 44 | let mut reader = CharReader::new(src); 45 | let mut row = Vec::new(); 46 | let mut cell = String::new(); 47 | let mut quoted = false; 48 | while let Some(c) = reader.next_char()? { 49 | if quoted { 50 | if c == '"' { 51 | if let Ok(Some('"')) = reader.peek_char() { 52 | // a sequence of 2 '"' is an escaped quote 53 | cell.push('"'); 54 | _ = reader.next_char(); // just consuming the quote 55 | } else { 56 | quoted = false; 57 | // next char should end the cell or row 58 | // (or it's an illformed cell) 59 | } 60 | } else { 61 | cell.push(c); 62 | } 63 | } else { 64 | match c { 65 | '"' => { 66 | // the csv format mandates that it's the first 67 | // char of the cell, we don't check that 68 | quoted = true; 69 | } 70 | c if c == separator => { 71 | let mut new_cell = String::new(); 72 | std::mem::swap(&mut cell, &mut new_cell); 73 | row.push(new_cell); 74 | quoted = false; 75 | } 76 | '\r' => { 77 | // it's either invalid or part of a CRLF, 78 | // we ignore it in both cases 79 | } 80 | '\n' => { 81 | let mut new_cell = String::new(); 82 | std::mem::swap(&mut cell, &mut new_cell); 83 | row.push(new_cell); 84 | let mut new_row = Vec::new(); 85 | std::mem::swap(&mut row, &mut new_row); 86 | self.rows.push(new_row); 87 | quoted = false; 88 | } 89 | _ => { 90 | cell.push(c); 91 | } 92 | } 93 | } 94 | } 95 | let mut new_cell = String::new(); 96 | std::mem::swap(&mut cell, &mut new_cell); 97 | row.push(new_cell); 98 | let mut new_row = Vec::new(); 99 | std::mem::swap(&mut row, &mut new_row); 100 | self.rows.push(new_row); 101 | Ok(()) 102 | } 103 | #[allow(dead_code)] 104 | pub fn row_count(&self) -> usize { 105 | self.rows.len() 106 | } 107 | pub fn col_count(&self) -> usize { 108 | self.rows.iter().map(|row| row.len()).max().unwrap_or(0) 109 | } 110 | pub fn into_entries(mut self) -> io::Result> { 111 | let cols = self.col_count(); 112 | if cols < 2 { 113 | return Err(io::Error::new( 114 | io::ErrorKind::Other, 115 | format!("Not enough columns ({cols})"), 116 | )); 117 | } 118 | let entries = self 119 | .rows 120 | .drain(..) 121 | .filter_map(|mut row| { 122 | if row.len() >= 2 { 123 | let name = row.remove(0); 124 | let value = row.swap_remove(0); 125 | Some(Entry::new(name, value)) 126 | } else { 127 | None 128 | } 129 | }) 130 | .collect(); 131 | Ok(entries) 132 | } 133 | } 134 | 135 | #[test] 136 | fn test_read_csv() { 137 | let con = "A1,B1\nA2,\"B,2\",\"\"\"\",D2\nA3 "; 138 | let csv = Csv::new(con.as_bytes(), ',').unwrap(); 139 | dbg!(&csv); 140 | assert_eq!(csv.rows.len(), 3); 141 | let row = &csv.rows[0]; 142 | assert_eq!(row.len(), 2); 143 | assert_eq!(row[0], "A1"); 144 | assert_eq!(row[1], "B1"); 145 | let row = &csv.rows[1]; 146 | assert_eq!(row.len(), 4); 147 | assert_eq!(row[0], "A2"); 148 | assert_eq!(row[1], "B,2"); 149 | assert_eq!(row[2], r#"""#); 150 | assert_eq!(row[3], "D2"); 151 | let row = &csv.rows[2]; 152 | assert_eq!(row.len(), 1); 153 | assert_eq!(row[0], "A3 "); 154 | } 155 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | /// SafeCloset error type 2 | #[derive(thiserror::Error, Debug)] 3 | pub enum SafeClosetError { 4 | #[error("IO error: {0}")] 5 | IO(#[from] std::io::Error), 6 | 7 | #[error("Core error: {0}")] 8 | Core(#[from] crate::core::CoreError), 9 | 10 | #[error("Termimad error: {0}")] 11 | Termimad(#[from] termimad::Error), 12 | 13 | #[error("Crossbeam channel error: {0}")] 14 | Crossbeam(#[from] crossbeam::channel::RecvError), 15 | } 16 | -------------------------------------------------------------------------------- /src/import/import_set.rs: -------------------------------------------------------------------------------- 1 | use crate::core::*; 2 | 3 | /// The subset of the drawer or csv file with the things to import 4 | #[derive(Debug, Default)] 5 | pub struct ImportSet { 6 | new_keys: Vec, 7 | different_values: Vec, 8 | } 9 | 10 | impl ImportSet { 11 | pub fn confirm_string(&self) -> String { 12 | format!( 13 | "The source contains {} new keys and {} new values.", 14 | self.new_keys.len(), 15 | self.different_values.len(), 16 | ) 17 | } 18 | } 19 | 20 | impl ImportSet { 21 | pub fn new( 22 | mut src: Vec, 23 | dst: &OpenDrawer, 24 | ) -> Self { 25 | let dst_entries = &dst.content.entries; 26 | let mut report = Self::default(); 27 | for src_entry in src.drain(..) { 28 | let dst_entry = dst_entries.iter().find(|&se| se.name == src_entry.name); 29 | if let Some(dst_entry) = dst_entry { 30 | if !dst_entry.value.contains(&src_entry.value) { 31 | report.different_values.push(src_entry); 32 | } 33 | } else { 34 | report.new_keys.push(src_entry); 35 | } 36 | } 37 | report 38 | } 39 | pub fn is_empty(&self) -> bool { 40 | self.new_keys.is_empty() && self.different_values.is_empty() 41 | } 42 | /// Import the set into the destination drawer and 43 | /// return a displayable report 44 | pub fn import_into( 45 | mut self, 46 | dst: &mut OpenDrawer, 47 | ) -> String { 48 | let report = format!( 49 | "{} added entries and {} enriched entries.\n\ 50 | Nothing is saved on disk until you save.", 51 | self.new_keys.len(), 52 | self.different_values.len(), 53 | ); 54 | let dst_entries = &mut dst.content.entries; 55 | for src_entry in self.different_values.drain(..) { 56 | for dst_entry in dst_entries.iter_mut() { 57 | if dst_entry.name == src_entry.name { 58 | dst_entry.value.push_str("\n---\n"); 59 | dst_entry.value.push_str(&src_entry.value); 60 | break; 61 | } 62 | } 63 | } 64 | dst_entries.append(&mut self.new_keys); 65 | report 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/import/mod.rs: -------------------------------------------------------------------------------- 1 | mod import_set; 2 | 3 | pub use import_set::*; 4 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | mod cli; 2 | mod core; 3 | mod csv; 4 | mod error; 5 | mod import; 6 | mod search; 7 | mod timer; 8 | mod tui; 9 | 10 | #[macro_use] 11 | extern crate cli_log; 12 | 13 | fn main() -> Result<(), error::SafeClosetError> { 14 | init_cli_log!(); 15 | cli::run()?; 16 | info!("bye"); 17 | Ok(()) 18 | } 19 | -------------------------------------------------------------------------------- /src/search/fuzzy_pattern.rs: -------------------------------------------------------------------------------- 1 | //! This is copied from Broot 2 | //! (there will probably be a common crate in the future) 3 | use { 4 | super::NameMatch, 5 | std::fmt::{ 6 | self, 7 | Write, 8 | }, 9 | }; 10 | 11 | // weights used in match score computing 12 | const BONUS_MATCH: i32 = 50_000; 13 | const BONUS_EXACT: i32 = 1_000; 14 | const BONUS_START: i32 = 10; 15 | const BONUS_START_WORD: i32 = 5; 16 | const BONUS_CANDIDATE_LENGTH: i32 = -1; // per char 17 | const BONUS_MATCH_LENGTH: i32 = -10; // per char of length of the match 18 | const BONUS_NB_HOLES: i32 = -30; // there's also a max on that number 19 | const BONUS_SINGLED_CHAR: i32 = -15; // when there's a char, neither first not last, isolated 20 | 21 | /// A pattern for fuzzy matching 22 | #[derive(Debug, Clone)] 23 | pub struct FuzzyPattern { 24 | chars: Box<[char]>, // secularized characters 25 | max_nb_holes: usize, 26 | } 27 | 28 | impl fmt::Display for FuzzyPattern { 29 | fn fmt( 30 | &self, 31 | f: &mut fmt::Formatter<'_>, 32 | ) -> fmt::Result { 33 | for &c in self.chars.iter() { 34 | f.write_char(c)? 35 | } 36 | Ok(()) 37 | } 38 | } 39 | 40 | enum MatchSearchResult { 41 | Perfect(NameMatch), // no need to test other positions 42 | Some(NameMatch), 43 | None, 44 | } 45 | 46 | fn is_word_separator(c: char) -> bool { 47 | matches!(c, '_' | ' ' | '-') 48 | } 49 | 50 | impl FuzzyPattern { 51 | /// build a pattern which will later be usable for fuzzy search. 52 | /// A pattern should be reused 53 | pub fn from(pat: &str) -> Self { 54 | let chars = secular::normalized_lower_lay_string(pat) 55 | .chars() 56 | .collect::>() 57 | .into_boxed_slice(); 58 | let max_nb_holes = match chars.len() { 59 | 1 => 0, 60 | 2 => 1, 61 | 3 => 2, 62 | 4 => 2, 63 | 5 => 2, 64 | 6 => 3, 65 | 7 => 3, 66 | 8 => 4, 67 | _ => chars.len() * 4 / 7, 68 | }; 69 | FuzzyPattern { 70 | chars, 71 | max_nb_holes, 72 | } 73 | } 74 | 75 | fn tight_match_from_index( 76 | &self, 77 | cand_chars: &[char], 78 | start_idx: usize, // start index in candidate, in chars 79 | ) -> MatchSearchResult { 80 | let mut pos = vec![0; self.chars.len()]; // positions of matching chars in candidate 81 | let mut cand_idx = start_idx; 82 | let mut pat_idx = 0; // index both in self.chars and pos 83 | let mut in_hole = false; 84 | loop { 85 | if cand_chars[cand_idx] == self.chars[pat_idx] { 86 | pos[pat_idx] = cand_idx; 87 | if in_hole { 88 | // We're no more in a hole. 89 | // Let's look if we can bring back the chars before the hole 90 | let mut rev_idx = 1; 91 | loop { 92 | if pat_idx < rev_idx { 93 | break; 94 | } 95 | if cand_chars[cand_idx - rev_idx] == self.chars[pat_idx - rev_idx] { 96 | // we move the pos forward 97 | pos[pat_idx - rev_idx] = cand_idx - rev_idx; 98 | } else { 99 | break; 100 | } 101 | rev_idx += 1; 102 | } 103 | in_hole = false; 104 | } 105 | pat_idx += 1; 106 | if pat_idx == self.chars.len() { 107 | break; // match, finished 108 | } 109 | } else { 110 | // there's a hole 111 | if cand_chars.len() - cand_idx <= self.chars.len() - pat_idx { 112 | return MatchSearchResult::None; 113 | } 114 | in_hole = true; 115 | } 116 | cand_idx += 1; 117 | } 118 | let mut nb_holes = 0; 119 | let mut nb_singled_chars = 0; 120 | for idx in 1..pos.len() { 121 | if pos[idx] > 1 + pos[idx - 1] { 122 | nb_holes += 1; 123 | if idx > 1 && pos[idx - 1] > 1 + pos[idx - 2] { 124 | // we improve a simple case: the one of a singleton which was created 125 | // by pushing forward a char 126 | if cand_chars[pos[idx - 2] + 1] == cand_chars[pos[idx - 1]] { 127 | // in some cases we're really removing another singletons but 128 | // let's forget this 129 | pos[idx - 1] = pos[idx - 2] + 1; 130 | nb_holes -= 1; 131 | } else { 132 | nb_singled_chars += 1; 133 | } 134 | } 135 | } 136 | } 137 | if nb_holes > self.max_nb_holes { 138 | return MatchSearchResult::None; 139 | } 140 | let match_len = 1 + cand_idx - pos[0]; 141 | let mut score = BONUS_MATCH; 142 | score += BONUS_CANDIDATE_LENGTH * (cand_chars.len() as i32); 143 | score += BONUS_SINGLED_CHAR * nb_singled_chars; 144 | score += BONUS_NB_HOLES * (nb_holes as i32); 145 | score += match_len as i32 * BONUS_MATCH_LENGTH; 146 | if pos[0] == 0 { 147 | score += BONUS_START + BONUS_START_WORD; 148 | if cand_chars.len() == self.chars.len() { 149 | score += BONUS_EXACT; 150 | return MatchSearchResult::Perfect(NameMatch { score, pos }); 151 | } 152 | } else { 153 | let previous = cand_chars[pos[0] - 1]; 154 | if is_word_separator(previous) { 155 | score += BONUS_START_WORD; 156 | if cand_chars.len() - pos[0] == self.chars.len() { 157 | return MatchSearchResult::Perfect(NameMatch { score, pos }); 158 | } 159 | } 160 | } 161 | MatchSearchResult::Some(NameMatch { score, pos }) 162 | } 163 | 164 | /// return a match if the pattern can be found in the candidate string. 165 | /// The algorithm tries to return the best one. For example if you search 166 | /// "abc" in "ababca-abc", the returned match would be at the end. 167 | pub fn find( 168 | &self, 169 | candidate: &str, 170 | ) -> Option { 171 | if candidate.len() < self.chars.len() { 172 | return None; 173 | } 174 | let mut cand_chars: Vec = Vec::with_capacity(candidate.len()); 175 | cand_chars.extend(candidate.chars().map(secular::lower_lay_char)); 176 | if cand_chars.len() < self.chars.len() { 177 | return None; 178 | } 179 | let mut best_score = 0; 180 | let mut best_match: Option = None; 181 | let n = cand_chars.len() - self.chars.len(); 182 | for start_idx in 0..=n { 183 | if cand_chars[start_idx] == self.chars[0] { 184 | match self.tight_match_from_index(&cand_chars, start_idx) { 185 | MatchSearchResult::Perfect(m) => { 186 | return Some(m); 187 | } 188 | MatchSearchResult::Some(m) => { 189 | if m.score > best_score { 190 | best_score = m.score; 191 | best_match = Some(m); 192 | } 193 | // we could make start_idx jump to pos[0] here 194 | // but it doesn't improve the perfs (it's rare 195 | // anyway to have pos[0] much greater than the 196 | // start of the search) 197 | } 198 | _ => {} 199 | } 200 | } 201 | } 202 | best_match 203 | } 204 | } 205 | -------------------------------------------------------------------------------- /src/search/mod.rs: -------------------------------------------------------------------------------- 1 | mod fuzzy_pattern; 2 | mod name_match; 3 | mod pos; 4 | 5 | pub use { 6 | fuzzy_pattern::FuzzyPattern, 7 | name_match::NameMatch, 8 | pos::*, 9 | }; 10 | -------------------------------------------------------------------------------- /src/search/name_match.rs: -------------------------------------------------------------------------------- 1 | use super::Pos; 2 | 3 | /// A NameMatch is a positive result of pattern matching inside a filename or subpath 4 | #[derive(Debug, Clone)] 5 | pub struct NameMatch { 6 | pub score: i32, // score of the match, guaranteed strictly positive, bigger is better 7 | pub pos: Pos, // positions of the matching chars 8 | } 9 | -------------------------------------------------------------------------------- /src/search/pos.rs: -------------------------------------------------------------------------------- 1 | /// a vector of indexes of the matching characters (not bytes) 2 | pub type Pos = Vec; 3 | -------------------------------------------------------------------------------- /src/timer/mod.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crossbeam::channel::{ 3 | Receiver, 4 | bounded, 5 | }, 6 | std::{ 7 | sync::{ 8 | Arc, 9 | Condvar, 10 | Mutex, 11 | }, 12 | thread, 13 | time::Duration, 14 | }, 15 | }; 16 | 17 | /// The timer isn't really precise but should be immune to 18 | /// changes on system's time. 19 | pub struct Timer { 20 | pair: Arc<(Mutex>, Condvar)>, 21 | } 22 | 23 | #[derive(Debug, Clone, Copy)] 24 | enum TimerCommand { 25 | #[allow(dead_code)] 26 | RingNow, 27 | Reset, 28 | #[allow(dead_code)] 29 | Stop, 30 | } 31 | 32 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 33 | pub enum TimerResult { 34 | CommandedRing, 35 | TimeoutRing, 36 | Stopped, 37 | Crash, 38 | } 39 | 40 | impl Timer { 41 | pub fn new(delay: Duration) -> (Self, Receiver) { 42 | let cmd: Option = None; 43 | let pair = Arc::new((Mutex::new(cmd), Condvar::new())); 44 | let timer_pair = Arc::clone(&pair); 45 | let (tx_ring, rx_ring) = bounded(1); 46 | thread::spawn(move || { 47 | let (cmd, cvar) = &*timer_pair; 48 | let mut cmd = cmd.lock().unwrap(); 49 | // we use the ring channel to notifiy the outside 50 | // that the thread is ready 51 | tx_ring.send(TimerResult::CommandedRing).unwrap(); 52 | loop { 53 | match cvar.wait_timeout_while(cmd, delay, |cmd| cmd.is_none()) { 54 | Ok((wcmd, wait_timeout_result)) => { 55 | cmd = wcmd; 56 | if wait_timeout_result.timed_out() { 57 | tx_ring.send(TimerResult::TimeoutRing).unwrap(); 58 | break; 59 | } 60 | match *cmd { 61 | Some(TimerCommand::RingNow) => { 62 | tx_ring.send(TimerResult::CommandedRing).unwrap(); 63 | break; 64 | } 65 | Some(TimerCommand::Reset) => { 66 | *cmd = None; 67 | } 68 | Some(TimerCommand::Stop) => { 69 | tx_ring.send(TimerResult::Stopped).unwrap(); 70 | break; 71 | } 72 | None => { 73 | warn!("unexpected lack of command in timer"); 74 | tx_ring.send(TimerResult::Crash).unwrap(); 75 | break; 76 | } 77 | } 78 | } 79 | Err(e) => { 80 | warn!("crash in timer: {}", e); 81 | tx_ring.send(TimerResult::Crash).unwrap(); 82 | break; 83 | } 84 | } 85 | } 86 | }); 87 | rx_ring.recv().unwrap(); // we wait for the thread to be started 88 | (Self { pair }, rx_ring) 89 | } 90 | fn send( 91 | &self, 92 | timer_command: TimerCommand, 93 | ) { 94 | let _ = self.pair.0.lock().unwrap().insert(timer_command); 95 | self.pair.1.notify_all(); 96 | } 97 | #[allow(dead_code)] 98 | pub fn stop(&self) { 99 | self.send(TimerCommand::Stop); 100 | } 101 | pub fn reset(&self) { 102 | self.send(TimerCommand::Reset); 103 | } 104 | #[allow(dead_code)] 105 | pub fn ring_now(&self) { 106 | self.send(TimerCommand::RingNow); 107 | } 108 | } 109 | 110 | #[cfg(test)] 111 | mod timer_tests { 112 | 113 | use { 114 | super::*, 115 | std::time::{ 116 | Duration, 117 | Instant, 118 | }, 119 | }; 120 | 121 | const MARGIN: Duration = Duration::from_millis(100); 122 | 123 | /// check that the uninterrupted timer rings after the required delay 124 | #[test] 125 | fn test_timer_timeout() { 126 | let delay = Duration::from_millis(10); 127 | let start = Instant::now(); 128 | let (_, timer_rx) = Timer::new(delay); 129 | let res = timer_rx.recv(); 130 | assert_eq!(res, Ok(TimerResult::TimeoutRing)); 131 | assert!(start.elapsed() < delay + MARGIN); // we don't want it to be too long 132 | assert!(start.elapsed() >= delay); 133 | } 134 | 135 | /// check that the timer has been immediately stopped 136 | #[test] 137 | fn test_timer_stop() { 138 | let delay = Duration::from_secs(1); 139 | let start = Instant::now(); 140 | let (timer, timer_rx) = Timer::new(delay); 141 | timer.stop(); 142 | let res = timer_rx.recv(); 143 | assert_eq!(res, Ok(TimerResult::Stopped)); 144 | assert!(start.elapsed() < MARGIN); 145 | } 146 | 147 | /// check that the timer immediately rings 148 | #[test] 149 | fn test_timer_ring_now() { 150 | let delay = Duration::from_secs(1); 151 | let start = Instant::now(); 152 | let (timer, timer_rx) = Timer::new(delay); 153 | timer.ring_now(); 154 | let res = timer_rx.recv(); 155 | assert_eq!(res, Ok(TimerResult::CommandedRing)); 156 | assert!(start.elapsed() < MARGIN); 157 | } 158 | 159 | /// check that the timer has been reset before the timeout 160 | #[test] 161 | fn test_timer_reset() { 162 | let delay = Duration::from_millis(100); 163 | let start = Instant::now(); 164 | let (timer, timer_rx) = Timer::new(delay); 165 | thread::spawn(move || { 166 | for _ in 0..5 { 167 | thread::sleep(delay / 2); 168 | timer.reset(); 169 | } 170 | }); 171 | let res = timer_rx.recv(); 172 | assert_eq!(res, Ok(TimerResult::TimeoutRing)); 173 | // we've reset 5 times after having waited for half the 174 | // delay, so the total duration should be more than 175 | // twice the delay 176 | assert!(start.elapsed() > 2 * delay); 177 | // but not too long 178 | assert!(start.elapsed() < 5 * delay); 179 | } 180 | } 181 | -------------------------------------------------------------------------------- /src/tui/action.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::*, 3 | std::fmt, 4 | }; 5 | 6 | macro_rules! make_actions { 7 | { 8 | $( $variant:ident $label:literal $($key:expr)* , )* 9 | } => { 10 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 11 | pub enum Action { 12 | $( $variant, )* 13 | } 14 | impl Action { 15 | pub fn label(self) -> &'static str { 16 | match self { 17 | $( Action::$variant => $label, )* 18 | } 19 | } 20 | #[allow(unreachable_code)] 21 | pub fn key(self) -> Option { 22 | match self { 23 | $( Action::$variant => { 24 | $( 25 | return Some($key); 26 | )* 27 | return None; 28 | })* 29 | } 30 | } 31 | pub fn for_key(mut key: KeyCombination) -> Option { 32 | // small hack because on Windows/Azerty I seem 33 | // to receive 'shift-?' for '?' from crossterm 34 | if key == key!(shift-'?') { 35 | key = key!('?'); 36 | } 37 | $( 38 | $( 39 | if key == $key { 40 | return Some(Action::$variant); 41 | } 42 | )* 43 | )* 44 | return None; 45 | } 46 | } 47 | impl fmt::Display for Action { 48 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 49 | write!(f, "{}", self.label()) 50 | } 51 | } 52 | } 53 | } 54 | 55 | // Define the actions that can be part of the menus 56 | make_actions! { 57 | Back "back" key!(esc), 58 | CloseAllValues "*F*old All unselected Values" key!(ctrl-f), 59 | CloseDeepDrawer "go to *U*pper drawer" key!(ctrl-U), 60 | CloseShallowDrawer "Close drawer" key!(ctrl-U), 61 | ConfirmEntryRemoval "Confirm Entry Removal" key!(y), 62 | Copy "*C*opy" key!(ctrl-C), 63 | Cut "*C*ut" key!(ctrl-X), 64 | EditClosetComments "Edit Closet Comments", 65 | GroupMatchingEntries "Group Matching Entries", 66 | Help "Help" key!('?'), 67 | Import "Import", 68 | SwapLineDown "Swap Line Down" key!(ctrl-down), 69 | SwapLineUp "Swap Line Up" key!(ctrl-up), 70 | NewDrawer "*N*ew Drawer" key!(ctrl-N), 71 | NewEntry "New Entry" key!(n), 72 | NewEntryAfterCurrent "New Entry After Current" key!(shift-n), 73 | OpenAllValues "Un*f*old All Values" key!(ctrl-F), 74 | OpenDrawer "*O*pen Drawer" key!(ctrl-O), 75 | OpenPasswordChangeDialog "Change Drawer Password", 76 | Paste "Paste" key!(ctrl-V), 77 | Quit "*Q*uit" key!(ctrl-Q), 78 | RemoveLine "Remove Line" key!(d), 79 | SaveDrawer "*S*ave Drawer" key!(ctrl-S), 80 | Search "Search" key!('/'), 81 | Sort "Sort", 82 | ToggleHiding "Toggle *H*iding" key!(ctrl-H), // hiding either pwd chars or unselected values 83 | ToggleMarkdown "Toggle Markdown", 84 | } 85 | -------------------------------------------------------------------------------- /src/tui/app.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::{ 4 | cli::Args, 5 | core::OpenCloset, 6 | error::SafeClosetError, 7 | timer::Timer, 8 | }, 9 | crokey::{ 10 | KeyCombination, 11 | crossterm::event::Event, 12 | }, 13 | crossbeam::select, 14 | termimad::{ 15 | Area, 16 | EventSource, 17 | }, 18 | }; 19 | 20 | /// Run the Terminal User Interface until the user decides to quit. 21 | /// 22 | /// The terminal must be already in alternate and raw mode 23 | pub(super) fn run( 24 | w: &mut W, 25 | open_closet: OpenCloset, 26 | args: &Args, 27 | ) -> Result<(), SafeClosetError> { 28 | let mut state = AppState::new(open_closet, args); 29 | let skin = AppSkin::default(); 30 | let mut view = GlobalView::default(); 31 | view.set_available_area(Area::full_screen()); 32 | view.draw(w, &mut state, &skin)?; 33 | let event_source = EventSource::new()?; 34 | let events = event_source.receiver(); 35 | let (timer, timer_rx) = Timer::new(MAX_INACTIVITY); 36 | loop { 37 | select! { 38 | // user events 39 | recv(events) -> timed_event => { 40 | let timed_event = timed_event?; 41 | let mut quit = false; 42 | match timed_event.event { 43 | Event::Resize(width, height) => { 44 | view.set_available_area(Area::new(0, 0, width, height)); 45 | } 46 | Event::Key(key) => { 47 | let key_combination = KeyCombination::from(key); 48 | debug!("key combination pressed: {}", key_combination); 49 | let cmd_result = state.on_key(key_combination)?; 50 | if cmd_result.quit() { 51 | debug!("user requests quit"); 52 | quit = true; 53 | } 54 | timer.reset(); 55 | } 56 | Event::Mouse(mouse_event) => { 57 | state.on_mouse_event(mouse_event, timed_event.double_click)?; 58 | timer.reset(); 59 | } 60 | Event::FocusGained | Event::FocusLost | Event::Paste(_) => { 61 | debug!("ignoring event: {:?}", timed_event.event); 62 | } 63 | } 64 | event_source.unblock(quit); 65 | if quit { 66 | break; 67 | } 68 | view.draw(w, &mut state, &skin)?; 69 | while state.has_pending_task() { 70 | let cmd_result = state.run_pending_task()?; 71 | if cmd_result.quit() { 72 | debug!("quit on end of pending task"); 73 | quit = true; 74 | } 75 | view.draw(w, &mut state, &skin)?; 76 | } 77 | if quit { 78 | break; 79 | } 80 | } 81 | 82 | // timer (so that safecloset doesn't stay open 83 | // if you quit your PC) 84 | recv(timer_rx) -> ring => { 85 | info!("Inactivity detection, quitting (delay: {:?})", MAX_INACTIVITY); 86 | debug!("ring type: {:?}", ring); 87 | event_source.unblock(true); 88 | break; 89 | } 90 | } 91 | } 92 | Ok(()) 93 | } 94 | -------------------------------------------------------------------------------- /src/tui/cmd_result.rs: -------------------------------------------------------------------------------- 1 | /// The result of handling an event 2 | #[derive(Debug, Clone, Copy, PartialEq, Eq)] 3 | pub enum CmdResult { 4 | Stay, 5 | Quit, 6 | } 7 | 8 | impl Default for CmdResult { 9 | fn default() -> Self { 10 | Self::Stay 11 | } 12 | } 13 | 14 | impl CmdResult { 15 | pub fn quit(self) -> bool { 16 | self == Self::Quit 17 | } 18 | } 19 | -------------------------------------------------------------------------------- /src/tui/comments_editor/comments_editor_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::tui::ContentSkin, 3 | crokey::{ 4 | KeyCombination, 5 | crossterm::event::MouseEvent, 6 | key, 7 | }, 8 | termimad::*, 9 | }; 10 | 11 | pub struct CommentsEditorState { 12 | pub comments: InputField, 13 | } 14 | 15 | impl CommentsEditorState { 16 | pub fn new(content: &str) -> Self { 17 | let mut comments = ContentSkin::make_input(); 18 | comments.new_line_on(key!(alt - enter)); 19 | comments.new_line_on(key!(ctrl - enter)); 20 | comments.set_str(content); 21 | Self { comments } 22 | } 23 | pub fn apply_key_event( 24 | &mut self, 25 | key: KeyCombination, 26 | ) -> bool { 27 | self.comments.apply_key_combination(key) 28 | } 29 | /// handle a mouse event 30 | pub fn on_mouse_event( 31 | &mut self, 32 | mouse_event: MouseEvent, 33 | double_click: bool, 34 | ) { 35 | self.comments.apply_mouse_event(mouse_event, double_click); 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tui/comments_editor/comments_editor_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::*, 4 | termimad::*, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct CommentsEditorView { 9 | area: Area, 10 | } 11 | 12 | static MD_BEFORE: &str = r#"Closet comments (not crypted):"#; 13 | static MD_AFTER: &str = r#"Hit *^enter* or *alt-enter* to add a line, and *enter* to validate"#; 14 | 15 | impl CommentsEditorView {} 16 | 17 | impl View for CommentsEditorView { 18 | fn set_available_area( 19 | &mut self, 20 | mut area: Area, 21 | ) { 22 | if area.width > 60 && area.height > 11 { 23 | area.left = 2; 24 | area.width -= 4; 25 | area.top += 3; 26 | area.height -= 6; 27 | } 28 | self.area = area; 29 | } 30 | 31 | /// Render the view in its area 32 | fn draw( 33 | &mut self, 34 | w: &mut W, 35 | state: &mut CommentsEditorState, // mutable to allow adapt to terminal size changes 36 | skin: &AppSkin, 37 | ) -> Result<(), SafeClosetError> { 38 | // border 39 | let border_colors = skin.dialog.md.table.compound_style.clone(); 40 | let area = &self.area; 41 | let mut rect = Rect::new(area.clone(), border_colors); 42 | rect.set_fill(true); 43 | rect.set_border_style(BORDER_STYLE_BLAND); 44 | rect.draw(w)?; 45 | 46 | // introduction 47 | let intro_area = Area::new(area.left + 1, area.top + 1, area.width - 2, 3); 48 | skin.dialog.md.write_in_area_on(w, MD_BEFORE, &intro_area)?; 49 | 50 | // comments textarea 51 | let text_area = Area::new(area.left + 1, area.top + 3, area.width - 2, area.height - 7); 52 | state.comments.set_area(text_area); 53 | state.comments.display_on(w)?; 54 | 55 | // chars hiding 56 | let after_area = Area::new(area.left + 1, area.bottom() - 3, area.width - 2, 3); 57 | skin.dialog.md.write_in_area_on(w, MD_AFTER, &after_area)?; 58 | 59 | Ok(()) 60 | } 61 | } 62 | -------------------------------------------------------------------------------- /src/tui/comments_editor/mod.rs: -------------------------------------------------------------------------------- 1 | mod comments_editor_state; 2 | mod comments_editor_view; 3 | 4 | pub use { 5 | comments_editor_state::*, 6 | comments_editor_view::*, 7 | }; 8 | 9 | use { 10 | super::*, 11 | crokey::{ 12 | KeyCombination, 13 | crossterm::event::MouseEvent, 14 | }, 15 | }; 16 | 17 | pub struct CommentsEditor { 18 | state: CommentsEditorState, 19 | pub view: CommentsEditorView, 20 | } 21 | 22 | impl CommentsEditor { 23 | pub fn new(comments: &str) -> Self { 24 | let state = CommentsEditorState::new(comments); 25 | let view = CommentsEditorView::default(); 26 | Self { state, view } 27 | } 28 | pub fn apply_key_event( 29 | &mut self, 30 | key: KeyCombination, 31 | ) -> bool { 32 | self.state.apply_key_event(key) 33 | } 34 | pub fn on_mouse_event( 35 | &mut self, 36 | mouse_event: MouseEvent, 37 | double_click: bool, 38 | ) { 39 | self.state.on_mouse_event(mouse_event, double_click); 40 | } 41 | pub fn get_comments(&mut self) -> String { 42 | self.state.comments.get_content() 43 | } 44 | pub fn draw( 45 | &mut self, 46 | w: &mut W, 47 | app_skin: &AppSkin, 48 | ) -> Result<(), SafeClosetError> { 49 | self.view.draw(w, &mut self.state, app_skin) 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /src/tui/dialog.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | /// the dialog that may be displayed over the drawer 4 | #[allow(clippy::large_enum_variant)] 5 | pub enum Dialog { 6 | None, 7 | Menu(ActionMenu), 8 | Help(Help), 9 | Password(PasswordDialog), 10 | CommentsEditor(CommentsEditor), 11 | Import(Import), 12 | } 13 | 14 | impl Dialog { 15 | pub fn is_none(&self) -> bool { 16 | matches!(self, Self::None) 17 | } 18 | pub fn is_some(&self) -> bool { 19 | !self.is_none() 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/tui/drawer_drawing_layout.rs: -------------------------------------------------------------------------------- 1 | use termimad::Area; 2 | 3 | #[derive(Clone, Default, Debug)] 4 | pub struct DrawerDrawingLayout { 5 | /// the area containing the lines, without the header 6 | pub lines_area: Area, 7 | 8 | pub name_width: u16, 9 | 10 | /// heights of values, excluding entries filtered out by search 11 | pub value_heights_by_line: Vec, 12 | 13 | pub content_height: usize, 14 | 15 | pub has_scrollbar: bool, 16 | } 17 | 18 | impl DrawerDrawingLayout { 19 | pub fn is_in_name_column( 20 | &self, 21 | x: u16, 22 | ) -> bool { 23 | x <= self.name_width 24 | } 25 | pub fn value_width(&self) -> usize { 26 | let name_width = self.name_width as usize; 27 | let value_left = name_width + 2; // 1 for selection mark, one for '|' 28 | self.lines_area.width as usize - value_left 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /src/tui/drawer_focus.rs: -------------------------------------------------------------------------------- 1 | use { 2 | std::fmt, 3 | termimad::InputField, 4 | }; 5 | 6 | /// Where the focus of the user/app is in the drawer, 7 | /// and the related data. 8 | /// 9 | /// The 'line' in most variant is the index among matched 10 | /// entries in case of filtering. 11 | pub enum DrawerFocus { 12 | NoneSelected, 13 | NameSelected { line: usize }, 14 | ValueSelected { line: usize }, 15 | NameEdit { line: usize, input: InputField }, 16 | ValueEdit { line: usize, input: InputField }, 17 | SearchEdit { previous_idx: Option }, 18 | PendingRemoval { line: usize }, 19 | } 20 | 21 | impl DrawerFocus { 22 | /// Return the index of the currently selected or 23 | /// edited entry, if any. 24 | pub fn line(&self) -> Option { 25 | match self { 26 | Self::NoneSelected => None, 27 | Self::NameSelected { line } => Some(*line), 28 | Self::ValueSelected { line } => Some(*line), 29 | Self::NameEdit { line, .. } => Some(*line), 30 | Self::ValueEdit { line, .. } => Some(*line), 31 | Self::SearchEdit { .. } => None, 32 | Self::PendingRemoval { line } => Some(*line), 33 | } 34 | } 35 | /// Return the mutable selection, when it's mutable 36 | /// (ie not for the edit as the input fields would 37 | /// hold data unrelated to the selection) 38 | pub fn selection_mut(&mut self) -> Option<&mut usize> { 39 | match self { 40 | Self::NameSelected { line } => Some(line), 41 | Self::ValueSelected { line } => Some(line), 42 | _ => None, 43 | } 44 | } 45 | pub fn is_search(&self) -> bool { 46 | matches!(self, DrawerFocus::SearchEdit { .. }) 47 | } 48 | pub fn is_name_selected( 49 | &self, 50 | entry_line: usize, 51 | ) -> bool { 52 | match self { 53 | Self::NameSelected { line } => *line == entry_line, 54 | _ => false, 55 | } 56 | } 57 | pub fn is_line_pending_removal( 58 | &self, 59 | entry_line: usize, 60 | ) -> bool { 61 | match self { 62 | Self::PendingRemoval { line } => *line == entry_line, 63 | _ => false, 64 | } 65 | } 66 | pub fn is_value_selected( 67 | &self, 68 | entry_line: usize, 69 | ) -> bool { 70 | match self { 71 | Self::ValueSelected { line } => *line == entry_line, 72 | _ => false, 73 | } 74 | } 75 | pub fn is_entry_edit(&self) -> bool { 76 | matches!( 77 | self, 78 | DrawerFocus::NameEdit { .. } | DrawerFocus::ValueEdit { .. } 79 | ) 80 | } 81 | /// return the input editing the name of the entry 82 | /// of given index, if it's currently edited 83 | pub fn name_input( 84 | &mut self, 85 | entry_line: usize, 86 | ) -> Option<&mut InputField> { 87 | match self { 88 | Self::NameEdit { line, input } if *line == entry_line => Some(input), 89 | _ => None, 90 | } 91 | } 92 | /// return the input editing the value of the entry 93 | /// of given index, if it's currently edited 94 | pub fn value_input( 95 | &mut self, 96 | entry_line: usize, 97 | ) -> Option<&mut InputField> { 98 | match self { 99 | Self::ValueEdit { line, input } if *line == entry_line => Some(input), 100 | _ => None, 101 | } 102 | } 103 | } 104 | 105 | impl fmt::Debug for DrawerFocus { 106 | fn fmt( 107 | &self, 108 | f: &mut fmt::Formatter<'_>, 109 | ) -> fmt::Result { 110 | match self { 111 | Self::NoneSelected => f.debug_struct("NoneSelected").finish(), 112 | Self::NameSelected { line } => { 113 | f.debug_struct("NameSelected").field("line", line).finish() 114 | } 115 | Self::ValueSelected { line } => { 116 | f.debug_struct("ValueSelected").field("line", line).finish() 117 | } 118 | Self::NameEdit { line, .. } => f.debug_struct("NameEdit").field("line", line).finish(), 119 | Self::ValueEdit { line, .. } => { 120 | f.debug_struct("ValueEdit").field("line", line).finish() 121 | } 122 | Self::SearchEdit { .. } => f.debug_struct("SearchEdit").finish(), 123 | Self::PendingRemoval { line } => f 124 | .debug_struct("PendingRemoval") 125 | .field("line", line) 126 | .finish(), 127 | } 128 | } 129 | } 130 | -------------------------------------------------------------------------------- /src/tui/entry_ref.rs: -------------------------------------------------------------------------------- 1 | 2 | 3 | /// a type used for entry display, valable when there 4 | /// is or there isn't a search 5 | pub struct EntryRef<'d, 's> { 6 | 7 | /// idx of the entry in the drawer 8 | pub idx: usize, 9 | 10 | } 11 | -------------------------------------------------------------------------------- /src/tui/file_selector/file_selector_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::ContentSkin, 4 | crokey::{ 5 | KeyCombination, 6 | crossterm::event::MouseEvent, 7 | }, 8 | std::path::{ 9 | Path, 10 | PathBuf, 11 | }, 12 | termimad::*, 13 | }; 14 | 15 | pub struct FileSelectorState { 16 | pub intro: String, 17 | pub file_type: FileType, 18 | pub input: InputField, 19 | pub path: Option, // The result, if any 20 | pub message: &'static str, 21 | } 22 | 23 | impl FileSelectorState { 24 | pub fn new( 25 | intro: String, 26 | file_type: FileType, 27 | ) -> Self { 28 | let input = ContentSkin::make_input(); 29 | let path = None; 30 | let message = file_type.check(&PathBuf::new()).message; 31 | Self { 32 | intro, 33 | file_type, 34 | input, 35 | path, 36 | message, 37 | } 38 | } 39 | pub fn get_selected_file(&self) -> Option<&Path> { 40 | self.path.as_deref() 41 | } 42 | fn update_path(&mut self) { 43 | let path: PathBuf = self.input.get_content().into(); 44 | let check = self.file_type.check(&path); 45 | self.path = check.ok.then_some(path); 46 | self.message = check.message; 47 | } 48 | pub fn apply_key_event( 49 | &mut self, 50 | key: KeyCombination, 51 | ) -> bool { 52 | let b = self.input.apply_key_combination(key); 53 | self.update_path(); 54 | b 55 | } 56 | /// handle a mouse event 57 | pub fn on_mouse_event( 58 | &mut self, 59 | mouse_event: MouseEvent, 60 | double_click: bool, 61 | ) { 62 | self.input.apply_mouse_event(mouse_event, double_click); 63 | self.update_path(); 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/tui/file_selector/file_selector_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::*, 4 | termimad::*, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct FileSelectorView { 9 | available_area: Area, 10 | } 11 | 12 | impl FileSelectorView { 13 | fn compute_area_width(&self) -> u16 { 14 | let screen = &self.available_area; 15 | let sw2 = screen.width / 2; 16 | let w2 = 27.min(sw2 - 3); // dialog half width 17 | w2 * 2 18 | } 19 | fn compute_area( 20 | &self, 21 | content_height: usize, 22 | area_width: u16, 23 | ) -> Area { 24 | let screen = &self.available_area; 25 | let ideal_height = content_height as u16 + 2; // margin of 1 26 | let left = (screen.width - area_width) / 2; 27 | let h = screen.height.min(ideal_height); 28 | let top = ((screen.height - h) * 3 / 5).max(1); 29 | Area::new(left, top, area_width, h) 30 | } 31 | } 32 | 33 | impl View for FileSelectorView { 34 | fn set_available_area( 35 | &mut self, 36 | area: Area, 37 | ) { 38 | self.available_area = area; 39 | } 40 | 41 | /// Render the view in its area 42 | fn draw( 43 | &mut self, 44 | w: &mut W, 45 | state: &mut FileSelectorState, // mutable to allow adapt to terminal size changes 46 | skin: &AppSkin, 47 | ) -> Result<(), SafeClosetError> { 48 | // computing the area from the intro size 49 | let area_width = self.compute_area_width(); 50 | let text_width = area_width - 2; 51 | let text = FmtText::from(&skin.dialog.md, &state.intro, Some(text_width as usize)); 52 | let content_height = text.lines.len() 53 | + 1 // input 54 | + 1; // 1 for margin 55 | let area = self.compute_area(content_height, area_width); 56 | let intro_height = text.lines.len() as u16; 57 | 58 | // border 59 | let border_colors = skin.dialog.md.table.compound_style.clone(); 60 | let mut rect = Rect::new(area.clone(), border_colors); 61 | rect.set_fill(true); 62 | rect.set_border_style(BORDER_STYLE_BLAND); 63 | rect.draw(w)?; 64 | 65 | // introduction 66 | let mut area = Area::new(area.left + 1, area.top + 1, text_width, intro_height); 67 | let mut view = TextView::from(&area, &text); 68 | view.show_scrollbar = false; 69 | view.write_on(w)?; 70 | 71 | // file path input 72 | area.top += intro_height + 1; 73 | state.input.change_area(area.left, area.top, area.width); 74 | state.input.display_on(w)?; 75 | 76 | Ok(()) 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /src/tui/file_selector/file_type.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | #[derive(Debug, Clone, Copy)] 4 | pub struct FileCheck { 5 | pub ok: bool, 6 | pub message: &'static str, 7 | } 8 | 9 | #[derive(Debug, Clone, Copy)] 10 | pub enum FileType { 11 | File, 12 | } 13 | 14 | impl FileCheck { 15 | pub fn new( 16 | ok: bool, 17 | message: &'static str, 18 | ) -> Self { 19 | Self { ok, message } 20 | } 21 | } 22 | 23 | impl FileType { 24 | pub fn check( 25 | self, 26 | path: &Path, 27 | ) -> FileCheck { 28 | if path.components().count() == 0 { 29 | return FileCheck::new(false, "Type the path to the file to open"); 30 | } 31 | if !path.exists() || !path.is_file() { 32 | return FileCheck::new(false, "Type the path of a file"); 33 | } 34 | // we don't check the extension because people can name files how they want 35 | FileCheck::new(true, "Type *enter* to select this file") 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /src/tui/file_selector/mod.rs: -------------------------------------------------------------------------------- 1 | mod file_selector_state; 2 | mod file_selector_view; 3 | mod file_type; 4 | 5 | pub use { 6 | file_selector_state::*, 7 | file_selector_view::*, 8 | file_type::*, 9 | }; 10 | 11 | use { 12 | super::*, 13 | crokey::{ 14 | KeyCombination, 15 | crossterm::event::MouseEvent, 16 | }, 17 | std::path::Path, 18 | }; 19 | 20 | pub struct FileSelector { 21 | state: FileSelectorState, 22 | pub view: FileSelectorView, 23 | } 24 | 25 | impl FileSelector { 26 | pub fn new( 27 | intro: String, 28 | file_type: FileType, 29 | ) -> Self { 30 | let state = FileSelectorState::new(intro, file_type); 31 | let view = FileSelectorView::default(); 32 | Self { state, view } 33 | } 34 | pub fn get_selected_file(&self) -> Option<&Path> { 35 | self.state.get_selected_file() 36 | } 37 | pub fn apply_key_event( 38 | &mut self, 39 | key: KeyCombination, 40 | ) -> bool { 41 | self.state.apply_key_event(key) 42 | } 43 | pub fn on_mouse_event( 44 | &mut self, 45 | mouse_event: MouseEvent, 46 | double_click: bool, 47 | ) { 48 | self.state.on_mouse_event(mouse_event, double_click); 49 | } 50 | pub fn get_message(&self) -> &'static str { 51 | self.state.message 52 | } 53 | pub fn draw( 54 | &mut self, 55 | w: &mut W, 56 | app_skin: &AppSkin, 57 | ) -> Result<(), SafeClosetError> { 58 | self.view.draw(w, &mut self.state, app_skin) 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/tui/global_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::error::SafeClosetError, 4 | termimad::Area, 5 | }; 6 | 7 | /// The view covering the whole terminal 8 | #[derive(Default)] 9 | pub struct GlobalView { 10 | area: Area, 11 | title: TitleView, 12 | content: ContentView, 13 | status: StatusView, 14 | } 15 | 16 | impl View for GlobalView { 17 | fn set_available_area( 18 | &mut self, 19 | area: Area, 20 | ) { 21 | self.area = area; 22 | self.title 23 | .set_available_area(Area::new(0, 0, self.area.width, 1)); 24 | self.content 25 | .set_available_area(Area::new(0, 1, self.area.width, self.area.height - 2)); 26 | self.status 27 | .set_available_area(Area::new(0, self.area.height - 1, self.area.width, 1)); 28 | } 29 | fn draw( 30 | &mut self, 31 | w: &mut W, 32 | state: &mut AppState, 33 | app_skin: &AppSkin, 34 | ) -> Result<(), SafeClosetError> { 35 | self.title.draw(w, state, app_skin)?; 36 | self.content.draw(w, state, app_skin)?; 37 | self.status.draw(w, state, app_skin)?; 38 | w.flush()?; 39 | Ok(()) 40 | } 41 | } 42 | -------------------------------------------------------------------------------- /src/tui/help.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crokey::{ 4 | KeyCombination, 5 | crossterm::event::{ 6 | MouseEvent, 7 | MouseEventKind, 8 | }, 9 | }, 10 | minimad::Text, 11 | termimad::*, 12 | }; 13 | 14 | #[derive(Debug)] 15 | pub struct Help { 16 | area: Area, 17 | scroll: usize, 18 | text: Text<'static>, 19 | } 20 | 21 | impl Default for Help { 22 | fn default() -> Self { 23 | Self { 24 | scroll: 0, 25 | area: Area::default(), 26 | text: help_text(), 27 | } 28 | } 29 | } 30 | 31 | impl Help { 32 | pub fn set_available_area( 33 | &mut self, 34 | area: Area, 35 | ) { 36 | self.area = area; 37 | } 38 | pub fn apply_key_event( 39 | &mut self, 40 | key: KeyCombination, 41 | ) -> bool { 42 | // the only events we're interested into are the ones which impact the 43 | // scroll position so we create a text view and ask it after the event 44 | // handling what's the new scroll 45 | let fmt_text = FmtText::from_text( 46 | termimad::get_default_skin(), 47 | self.text.clone(), 48 | Some((self.area.width - 1) as usize), 49 | ); 50 | let mut text_view = TextView::from(&self.area, &fmt_text); 51 | text_view.set_scroll(self.scroll); 52 | if text_view.apply_key_combination(key) { 53 | self.scroll = text_view.scroll; 54 | true 55 | } else { 56 | false 57 | } 58 | } 59 | /// handle a mouse event 60 | pub fn on_mouse_event( 61 | &mut self, 62 | mouse_event: MouseEvent, 63 | _double_click: bool, 64 | ) { 65 | match mouse_event.kind { 66 | MouseEventKind::ScrollUp if self.scroll > 0 => { 67 | self.scroll -= 1; 68 | } 69 | MouseEventKind::ScrollDown => { 70 | self.scroll += 1; // if it overflows, it will be fixed on draw 71 | } 72 | _ => {} 73 | } 74 | } 75 | pub fn draw( 76 | &mut self, 77 | w: &mut W, 78 | app_skin: &AppSkin, 79 | ) -> Result<(), SafeClosetError> { 80 | let fmt_text = FmtText::from_text( 81 | &app_skin.help, 82 | self.text.clone(), 83 | Some((self.area.width - 1) as usize), 84 | ); 85 | let mut text_view = TextView::from(&self.area, &fmt_text); 86 | text_view.set_scroll(self.scroll); 87 | text_view.write_on(w)?; 88 | self.scroll = text_view.scroll; 89 | Ok(()) 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /src/tui/help_content.rs: -------------------------------------------------------------------------------- 1 | use termimad::minimad::{ 2 | Text, 3 | TextTemplate, 4 | }; 5 | 6 | static MD: &str = r#" 7 | 8 | # SafeCloset ${version} 9 | 10 | SafeCloset is written by Denys Séguret. Source code and documentation can be found on *https://dystroy.org/safecloset/* 11 | 12 | SafeCloset stores secrets in drawers. A drawer may be either top-level, or hidden in another drawer. Each drawer is protected by a passphrase. 13 | 14 | SafeCloset leaves after 120 seconds of inactivity. 15 | 16 | ## Keyboard actions 17 | 18 | The *^* symbol in SafeCloset means that the *control* key must be pressed. 19 | 20 | |:-:|:-: 21 | |key|action 22 | |:-:|- 23 | | *^n* | Create a drawer (inside the current drawer, if one is open) 24 | | *^o* | Open a drawer 25 | | *^u* | Goes up, closing the current drawer (you're back in the upper level one if you close a deep drawer) 26 | | *^s* | Save the current drawer and all upper drawers 27 | | *^q* | Quit without saving (with no confirmation) 28 | | *n* | Create a new entry, at the end of the list 29 | | *N* | Create a new entry immediately after the selected one 30 | | *^h* | Toggle hiding either password chars or unselected values 31 | | *^f* | Toggle folding all values 32 | | */* | Start searching the current drawer (do *Enter* or use the down or up arrow key to freeze it) 33 | | */* then *esc* | Remove the current filtering 34 | | *esc* | Cancel current field edition or open a menu 35 | | *tab* | Create a new entry or edit the value if you're already editing an entry's name 36 | | arrow keys | Move selection, selecting either an entry name or a value 37 | | *^↑* | Move selected line up 38 | | *^↓* | Move selected line down 39 | | *i* or *insert* | Start editing the selected name or value, cursor at start 40 | | *a* | Start editing the selected name or value, cursor at end 41 | | *d* | Remove the selected entry (with confirmation) 42 | | *^c* | Copy the selection (or the entire field if not edited) 43 | | *^x* | Cut the selection 44 | | *^v* | Paste 45 | | *Enter* | Validate the current edition 46 | | *alt*-*Enter* or *^enter* | New line in the currently edited value 47 | |-|- 48 | 49 | ## Guarantees 50 | 51 | There's none. And I can do nothing for you if you forget your passphrase. 52 | 53 | 54 | "#; 55 | 56 | pub fn help_text() -> Text<'static> { 57 | let template = TextTemplate::from(MD); 58 | let mut expander = template.expander(); 59 | expander.set("version", env!("CARGO_PKG_VERSION")); 60 | expander.expand() 61 | } 62 | -------------------------------------------------------------------------------- /src/tui/import/choices.rs: -------------------------------------------------------------------------------- 1 | use std::fmt; 2 | 3 | #[derive(Debug, Clone, Copy)] 4 | pub enum OriginKind { 5 | LocalFile, 6 | OtherFile, 7 | } 8 | impl fmt::Display for OriginKind { 9 | fn fmt( 10 | &self, 11 | f: &mut fmt::Formatter<'_>, 12 | ) -> fmt::Result { 13 | match self { 14 | Self::LocalFile => write!(f, "Import from *s*ame file"), 15 | Self::OtherFile => write!(f, "Import from *a*nother file"), 16 | } 17 | } 18 | } 19 | 20 | #[derive(Debug, Clone, Copy)] 21 | pub enum ConfirmCsv { 22 | Confirm, 23 | Cancel, 24 | } 25 | impl fmt::Display for ConfirmCsv { 26 | fn fmt( 27 | &self, 28 | f: &mut fmt::Formatter<'_>, 29 | ) -> fmt::Result { 30 | match self { 31 | Self::Confirm => write!(f, "Import these entries"), 32 | Self::Cancel => write!(f, "Cancel"), 33 | } 34 | } 35 | } 36 | 37 | #[derive(Debug, Clone, Copy)] 38 | pub enum ConfirmDrawer { 39 | Confirm, 40 | GoDeeper, 41 | Cancel, 42 | } 43 | impl fmt::Display for ConfirmDrawer { 44 | fn fmt( 45 | &self, 46 | f: &mut fmt::Formatter<'_>, 47 | ) -> fmt::Result { 48 | match self { 49 | Self::Confirm => write!(f, "Import this drawer"), 50 | Self::GoDeeper => write!(f, "Open a deeper drawer"), 51 | Self::Cancel => write!(f, "Cancel"), 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/tui/import/decide_origin_kind.rs: -------------------------------------------------------------------------------- 1 | 2 | 3 | pub struct DecideOriginKind { 4 | selection: OriginKind, 5 | } 6 | 7 | impl Default for DecideOriginKind { 8 | fn default() -> Self { 9 | Self { 10 | selection: OriginKind::LocalFile, 11 | } 12 | } 13 | } 14 | 15 | 16 | #[derive(Default)] 17 | pub struct DecideOriginKindView { 18 | available_area: Area, 19 | } 20 | 21 | impl View for DecideOriginKindView { 22 | type State = DecideOriginKindState; 23 | 24 | fn set_available_area(&mut self, available_area: Area) { 25 | if available_area != self.available_area { 26 | self.available_area = available_area; 27 | } 28 | } 29 | 30 | /// Draw the menu and set the area of all visible items in the state 31 | fn draw( 32 | &mut self, 33 | w: &mut W, 34 | state: &mut DecideOriginKind, 35 | app_skin: &AppSkin, 36 | ) -> Result<(), SafeClosetError> { 37 | Ok(()) 38 | } 39 | } 40 | 41 | -------------------------------------------------------------------------------- /src/tui/import/import_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::*, 4 | termimad::*, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct ImportView { 9 | available_area: Area, 10 | } 11 | 12 | impl ImportView { 13 | fn set_view_available_area( 14 | &self, 15 | state: &mut ImportState, 16 | ) { 17 | match &mut state.step { 18 | Step::DecideOriginKind(menu) => { 19 | menu.set_available_area(self.available_area.clone()); 20 | } 21 | Step::FileSelector(selector) => { 22 | selector 23 | .view 24 | .set_available_area(self.available_area.clone()); 25 | } 26 | Step::TypeDrawerPassword { dialog, .. } => { 27 | dialog.view.set_available_area(self.available_area.clone()); 28 | } 29 | Step::ConfirmImportCsv { menu, .. } => { 30 | menu.set_available_area(self.available_area.clone()); 31 | } 32 | Step::ConfirmImportDrawer { menu, .. } => { 33 | menu.set_available_area(self.available_area.clone()); 34 | } 35 | Step::InformEnd(menu) => { 36 | menu.set_available_area(self.available_area.clone()); 37 | } 38 | Step::Finished => {} 39 | } 40 | } 41 | } 42 | 43 | impl View for ImportView { 44 | fn set_available_area( 45 | &mut self, 46 | area: Area, 47 | ) { 48 | self.available_area = area; 49 | } 50 | 51 | /// Render the view in its area 52 | fn draw( 53 | &mut self, 54 | w: &mut W, 55 | state: &mut ImportState, // mutable to allow adapt to terminal size changes 56 | skin: &AppSkin, 57 | ) -> Result<(), SafeClosetError> { 58 | self.set_view_available_area(state); 59 | 60 | match &mut state.step { 61 | Step::DecideOriginKind(menu) => { 62 | menu.draw(w, skin)?; 63 | } 64 | Step::FileSelector(selector) => { 65 | selector.draw(w, skin)?; 66 | } 67 | Step::TypeDrawerPassword { dialog, .. } => { 68 | dialog.draw(w, skin)?; 69 | } 70 | Step::ConfirmImportCsv { menu, .. } => { 71 | menu.draw(w, skin)?; 72 | } 73 | Step::ConfirmImportDrawer { menu, .. } => { 74 | menu.draw(w, skin)?; 75 | } 76 | Step::InformEnd(menu) => { 77 | menu.draw(w, skin)?; 78 | } 79 | Step::Finished => {} 80 | } 81 | Ok(()) 82 | } 83 | } 84 | -------------------------------------------------------------------------------- /src/tui/import/mod.rs: -------------------------------------------------------------------------------- 1 | mod choices; 2 | mod import_state; 3 | mod import_view; 4 | 5 | pub use { 6 | choices::*, 7 | import_state::*, 8 | import_view::*, 9 | }; 10 | 11 | use { 12 | super::*, 13 | crokey::{ 14 | KeyCombination, 15 | crossterm::event::MouseEvent, 16 | }, 17 | std::path::PathBuf, 18 | termimad::Area, 19 | }; 20 | 21 | pub struct Import { 22 | state: ImportState, 23 | view: ImportView, 24 | } 25 | 26 | impl Import { 27 | pub fn new( 28 | dst_path: PathBuf, 29 | dst_drawer: DrawerState, 30 | ) -> Self { 31 | let state = ImportState::new(dst_path, dst_drawer); 32 | let view = ImportView::default(); 33 | Self { state, view } 34 | } 35 | pub fn toggle_hide_chars(&mut self) { 36 | self.state.toggle_hide_chars(); 37 | } 38 | pub fn on_key( 39 | &mut self, 40 | key: KeyCombination, 41 | ) -> bool { 42 | self.state.apply_key_event(key) 43 | } 44 | pub fn on_mouse_event( 45 | &mut self, 46 | mouse_event: MouseEvent, 47 | double_click: bool, 48 | ) { 49 | self.state.on_mouse_event(mouse_event, double_click); 50 | } 51 | pub fn set_available_area( 52 | &mut self, 53 | area: Area, 54 | ) { 55 | self.view.set_available_area(area); 56 | } 57 | pub fn draw( 58 | &mut self, 59 | w: &mut W, 60 | app_skin: &AppSkin, 61 | ) -> Result<(), SafeClosetError> { 62 | self.view.draw(w, &mut self.state, app_skin) 63 | } 64 | pub fn status(&self) -> &'static str { 65 | self.state.status() 66 | } 67 | pub fn take_back_drawer(self) -> DrawerState { 68 | self.state.dst_drawer_state 69 | } 70 | pub fn is_finished(&self) -> bool { 71 | self.state.is_finished() 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /src/tui/keys.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::*, 3 | once_cell::sync::Lazy, 4 | }; 5 | 6 | pub static KEY_FORMAT: Lazy = Lazy::new(|| { 7 | KeyCombinationFormat::default() 8 | .with_implicit_shift() 9 | .with_control("^") 10 | }); 11 | -------------------------------------------------------------------------------- /src/tui/matched_string.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::search::NameMatch, 3 | minimad::Alignment, 4 | std::io::Write, 5 | termimad::*, 6 | }; 7 | 8 | pub struct MatchedString<'a> { 9 | pub name_match: Option, 10 | pub string: &'a str, 11 | pub base_style: &'a CompoundStyle, 12 | pub match_style: &'a CompoundStyle, 13 | pub display_width: Option, 14 | pub align: Alignment, 15 | } 16 | 17 | impl<'a, 'w> MatchedString<'a> { 18 | pub fn new( 19 | name_match: Option, 20 | string: &'a str, 21 | base_style: &'a CompoundStyle, 22 | match_style: &'a CompoundStyle, 23 | ) -> Self { 24 | Self { 25 | name_match, 26 | string, 27 | base_style, 28 | match_style, 29 | display_width: None, 30 | align: Alignment::Left, 31 | } 32 | } 33 | pub fn queue_on( 34 | &self, 35 | cw: &mut CropWriter<'w, W>, 36 | ) -> Result<(), termimad::Error> { 37 | if let Some(m) = &self.name_match { 38 | let mut pos_idx: usize = 0; 39 | let mut combined_style = self.base_style.clone(); 40 | combined_style.overwrite_with(self.match_style); 41 | let mut right_filling = 0; 42 | let mut s = self.string; 43 | if let Some(dw) = self.display_width { 44 | let w = unicode_width::UnicodeWidthStr::width(s); 45 | #[allow(clippy::comparison_chain)] 46 | if w > dw { 47 | let (count_bytes, _) = StrFit::count_fitting(s, dw); 48 | s = &s[0..count_bytes]; 49 | } else if w < dw { 50 | match self.align { 51 | Alignment::Right => { 52 | cw.repeat(self.base_style, &SPACE_FILLING, dw - w)?; 53 | } 54 | Alignment::Center => { 55 | right_filling = (dw - w) / 2; 56 | cw.repeat(self.base_style, &SPACE_FILLING, dw - w - right_filling)?; 57 | } 58 | _ => { 59 | right_filling = dw - w; 60 | } 61 | } 62 | } 63 | } 64 | // we might call queue_char more than allowed but that's okay 65 | // because the cropwriter will crop them 66 | for (cand_idx, cand_char) in s.chars().enumerate() { 67 | if pos_idx < m.pos.len() && m.pos[pos_idx] == cand_idx { 68 | cw.queue_char(&combined_style, cand_char)?; 69 | pos_idx += 1; 70 | } else { 71 | cw.queue_char(self.base_style, cand_char)?; 72 | } 73 | } 74 | if right_filling > 0 { 75 | cw.repeat(self.base_style, &SPACE_FILLING, right_filling)?; 76 | } 77 | } else if let Some(w) = self.display_width { 78 | match self.align { 79 | Alignment::Center => { 80 | cw.queue_str(self.base_style, &format!("{:^w$}", self.string))?; 81 | } 82 | Alignment::Right => { 83 | cw.queue_str(self.base_style, &format!("{:>w$}", self.string))?; 84 | } 85 | _ => { 86 | cw.queue_str(self.base_style, &format!("{:; 7 | 8 | impl ActionMenu { 9 | pub fn add_action( 10 | &mut self, 11 | action: Action, 12 | ) { 13 | self.add_item(action, action.key()); 14 | } 15 | } 16 | -------------------------------------------------------------------------------- /src/tui/menu/confirm.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crokey::key, 4 | std::fmt, 5 | }; 6 | 7 | #[derive(Clone)] 8 | pub struct ConfirmOption { 9 | value: Option, 10 | } 11 | 12 | #[allow(dead_code)] 13 | impl ConfirmOption { 14 | pub fn confirmed(&self) -> bool { 15 | self.value.is_some() 16 | } 17 | } 18 | 19 | impl fmt::Display for ConfirmOption { 20 | fn fmt( 21 | &self, 22 | f: &mut fmt::Formatter, 23 | ) -> fmt::Result { 24 | match &self.value { 25 | Some(s) => write!(f, "{s}"), 26 | None => write!(f, "Cancel"), 27 | } 28 | } 29 | } 30 | 31 | #[allow(dead_code)] 32 | pub fn confirm( 33 | intro: S1, 34 | question: S2, 35 | ) -> Menu 36 | where 37 | S1: Into, 38 | S2: Into, 39 | { 40 | let mut menu = Menu::new(); 41 | menu.add_item( 42 | ConfirmOption { 43 | value: Some(question.into()), 44 | }, 45 | Some(key!(y)), 46 | ); 47 | menu.add_item(ConfirmOption { value: None }, Some(key!(n))); 48 | menu.state.set_intro(intro); 49 | menu 50 | } 51 | 52 | #[allow(dead_code)] 53 | pub type ConfirmMenu = Menu; 54 | -------------------------------------------------------------------------------- /src/tui/menu/inform.rs: -------------------------------------------------------------------------------- 1 | use super::*; 2 | 3 | static OK: &str = "OK"; 4 | 5 | pub fn inform>(txt: S) -> Menu<&'static str> { 6 | let mut menu = Menu::new(); 7 | menu.add_item(OK, None); 8 | menu.state.set_intro(txt); 9 | menu 10 | } 11 | 12 | pub type InformMenu = Menu<&'static str>; 13 | -------------------------------------------------------------------------------- /src/tui/menu/menu.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::tui::*, 3 | crokey::KeyCombination, 4 | termimad::Area, 5 | }; 6 | 7 | pub struct Menu { 8 | pub state: MenuState, 9 | view: MenuView, 10 | } 11 | 12 | impl Menu { 13 | pub fn new() -> Self { 14 | Self { 15 | state: Default::default(), 16 | view: Default::default(), 17 | } 18 | } 19 | pub fn draw( 20 | &mut self, 21 | w: &mut W, 22 | app_skin: &AppSkin, 23 | ) -> Result<(), SafeClosetError> { 24 | self.view.draw(w, &mut self.state, app_skin) 25 | } 26 | pub fn set_available_area( 27 | &mut self, 28 | area: Area, 29 | ) { 30 | >>::set_available_area(&mut self.view, area); 31 | } 32 | pub fn set_intro>( 33 | &mut self, 34 | intro: S, 35 | ) { 36 | self.state.set_intro(intro); 37 | } 38 | pub fn add_item( 39 | &mut self, 40 | action: I, 41 | key: Option, 42 | ) { 43 | self.state.add_item(action, key); 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /src/tui/menu/menu_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::{ 3 | KeyCombination, 4 | crossterm::event::{ 5 | MouseButton, 6 | MouseEvent, 7 | MouseEventKind, 8 | }, 9 | key, 10 | }, 11 | termimad::Area, 12 | }; 13 | 14 | pub struct MenuItem { 15 | pub action: I, 16 | pub area: Option, 17 | pub key: Option, 18 | } 19 | 20 | pub struct MenuState { 21 | pub intro: Option, 22 | pub items: Vec>, 23 | pub selection: usize, 24 | pub scroll: usize, 25 | } 26 | 27 | impl Default for MenuState { 28 | fn default() -> Self { 29 | Self { 30 | intro: None, 31 | items: Vec::new(), 32 | selection: 0, 33 | scroll: 0, 34 | } 35 | } 36 | } 37 | 38 | impl MenuState { 39 | pub fn set_intro>( 40 | &mut self, 41 | intro: S, 42 | ) { 43 | self.intro = Some(intro.into()); 44 | } 45 | pub fn add_item( 46 | &mut self, 47 | action: I, 48 | key: Option, 49 | ) { 50 | self.items.push(MenuItem { 51 | action, 52 | area: None, 53 | key, 54 | }); 55 | } 56 | pub fn clear_item_areas(&mut self) { 57 | for item in self.items.iter_mut() { 58 | item.area = None; 59 | } 60 | } 61 | pub fn select( 62 | &mut self, 63 | selection: usize, 64 | ) { 65 | self.selection = selection.min(self.items.len()); 66 | } 67 | pub(crate) fn fix_scroll( 68 | &mut self, 69 | page_height: usize, 70 | ) { 71 | let len = self.items.len(); 72 | let sel = self.selection; 73 | if len <= page_height || sel < 3 || sel <= page_height / 2 { 74 | self.scroll = 0; 75 | } else if sel + 3 >= len { 76 | self.scroll = len - page_height; 77 | } else { 78 | self.scroll = (sel - 2).min(len - page_height); 79 | } 80 | } 81 | /// Handle a key event (not triggering the actions on their keys, only apply 82 | /// the menu mechanics) 83 | pub fn on_key( 84 | &mut self, 85 | key: KeyCombination, 86 | ) -> Option { 87 | let items = &self.items; 88 | if key == key!(down) { 89 | self.selection = (self.selection + 1) % items.len(); 90 | } else if key == key!(up) { 91 | self.selection = (self.selection + items.len() - 1) % items.len(); 92 | } else if key == key!(enter) { 93 | return Some(items[self.selection].action.clone()); 94 | } 95 | for item in &self.items { 96 | if item.key == Some(key) { 97 | return Some(item.action.clone()); 98 | } 99 | } 100 | None 101 | } 102 | pub fn item_idx_at( 103 | &self, 104 | x: u16, 105 | y: u16, 106 | ) -> Option { 107 | for (idx, item) in self.items.iter().enumerate() { 108 | if let Some(area) = &item.area { 109 | if area.contains(x, y) { 110 | return Some(idx); 111 | } 112 | } 113 | } 114 | None 115 | } 116 | /// handle a mouse event, returning the triggered action if any (on 117 | /// double click only) 118 | pub fn on_mouse_event( 119 | &mut self, 120 | mouse_event: MouseEvent, 121 | double_click: bool, 122 | ) -> Option { 123 | let is_click = matches!( 124 | mouse_event.kind, 125 | MouseEventKind::Down(MouseButton::Left) | MouseEventKind::Up(MouseButton::Left), 126 | ); 127 | if is_click { 128 | if let Some(selection) = self.item_idx_at(mouse_event.column, mouse_event.row) { 129 | self.selection = selection; 130 | if double_click { 131 | return Some(self.items[self.selection].action.clone()); 132 | } 133 | } 134 | } 135 | None 136 | } 137 | } 138 | -------------------------------------------------------------------------------- /src/tui/menu/menu_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crate::tui::*, 3 | crokey::crossterm::{ 4 | queue, 5 | style::Print, 6 | }, 7 | termimad::{ 8 | minimad::*, 9 | *, 10 | }, 11 | }; 12 | 13 | /// The drawer of the menu 14 | #[derive(Default)] 15 | pub struct MenuView { 16 | available_area: Area, 17 | } 18 | 19 | impl View> for MenuView { 20 | fn set_available_area( 21 | &mut self, 22 | available_area: Area, 23 | ) { 24 | if available_area != self.available_area { 25 | self.available_area = available_area; 26 | } 27 | } 28 | 29 | /// Draw the menu and set the area of all visible items in the state 30 | fn draw( 31 | &mut self, 32 | w: &mut W, 33 | state: &mut MenuState, 34 | app_skin: &AppSkin, 35 | ) -> Result<(), SafeClosetError> { 36 | state.clear_item_areas(); 37 | let skin = &app_skin.dialog; 38 | let border_colors = skin.md.table.compound_style.clone(); 39 | let area_width = self.compute_area_width(); 40 | let mut intro_lines = Vec::new(); 41 | let text_width = area_width - 2; 42 | let intro = state.intro.clone(); 43 | let mut content_height = state.items.len(); 44 | if let Some(intro) = &intro { 45 | let text = FmtText::from(&skin.md, intro, Some(text_width as usize)); 46 | intro_lines = text.lines; 47 | content_height += intro_lines.len() + 1; // 1 for margin 48 | } 49 | let area = self.compute_area(content_height, area_width); 50 | let h = area.height as usize - 2; // internal height 51 | let scrollbar = compute_scrollbar(state.scroll, content_height, h, area.top + 1); 52 | state.fix_scroll(h); 53 | let mut rect = Rect::new(area.clone(), border_colors); 54 | rect.set_border_style(BORDER_STYLE_BLAND); 55 | rect.set_fill(true); 56 | rect.draw(w)?; 57 | let key_width = 3; 58 | let mut label_width = area.width as usize - key_width - 2; 59 | if scrollbar.is_some() { 60 | label_width -= 1; 61 | } 62 | let mut y = area.top; 63 | let mut items = state.items.iter_mut().enumerate().skip(state.scroll); 64 | for _ in 0..h { 65 | y += 1; 66 | if !intro_lines.is_empty() { 67 | let intro_line = intro_lines.remove(0); 68 | w.go_to(area.left + 1, y)?; 69 | let dl = DisplayableLine::new(&skin.md, &intro_line, Some(text_width as usize)); 70 | queue!(w, Print(&dl))?; 71 | if intro_lines.is_empty() { 72 | y += 1; // skip line for margin 73 | } 74 | continue; 75 | } 76 | if let Some((idx, item)) = items.next() { 77 | let item_area = Area::new(area.left + 1, y, area.width - 2, 1); 78 | let skin = if state.selection == idx { 79 | &skin.sel_md 80 | } else { 81 | &skin.md 82 | }; 83 | w.go_to(item_area.left, y)?; 84 | skin.write_composite_fill( 85 | w, 86 | Composite::from_inline(&item.action.to_string()), 87 | label_width, 88 | Alignment::Left, 89 | )?; 90 | let key_desc = item 91 | .key 92 | .map_or("".to_string(), |key| KEY_FORMAT.to_string(key)); 93 | skin.write_composite_fill( 94 | w, 95 | mad_inline!("*$0", &key_desc), 96 | key_width, 97 | Alignment::Right, 98 | )?; 99 | item.area = Some(item_area); 100 | } else { 101 | break; 102 | } 103 | if let Some((stop, sbottom)) = scrollbar { 104 | w.go_to(area.right() - 2, y)?; 105 | if stop <= y && y <= sbottom { 106 | skin.md.scrollbar.thumb.queue(w)?; 107 | } else { 108 | skin.md.scrollbar.track.queue(w)?; 109 | } 110 | } 111 | } 112 | Ok(()) 113 | } 114 | } 115 | 116 | impl MenuView { 117 | fn compute_area_width(&self) -> u16 { 118 | let screen = &self.available_area; 119 | let sw2 = screen.width / 2; 120 | let w2 = 24.min(sw2 - 3); // menu half width 121 | w2 * 2 122 | } 123 | fn compute_area( 124 | &self, 125 | content_height: usize, 126 | area_width: u16, 127 | ) -> Area { 128 | let screen = &self.available_area; 129 | let ideal_height = content_height as u16 + 2; // margin of 1 130 | let left = (screen.width - area_width) / 2; 131 | let h = screen.height.min(ideal_height); 132 | let top = ((screen.height - h) * 3 / 5).max(1); 133 | Area::new(left, top, area_width, h) 134 | } 135 | } 136 | -------------------------------------------------------------------------------- /src/tui/menu/mod.rs: -------------------------------------------------------------------------------- 1 | mod action_menu; 2 | mod confirm; 3 | mod inform; 4 | mod menu; 5 | mod menu_state; 6 | mod menu_view; 7 | 8 | pub use { 9 | action_menu::*, 10 | inform::*, 11 | menu::*, 12 | menu_state::*, 13 | menu_view::*, 14 | }; 15 | -------------------------------------------------------------------------------- /src/tui/message.rs: -------------------------------------------------------------------------------- 1 | /// A message displayed after an event has been handled 2 | pub struct Message { 3 | pub text: String, 4 | pub error: bool, 5 | } 6 | -------------------------------------------------------------------------------- /src/tui/mod.rs: -------------------------------------------------------------------------------- 1 | mod action; 2 | mod app; 3 | mod app_state; 4 | mod cmd_result; 5 | mod comments_editor; 6 | mod content_view; 7 | mod dialog; 8 | mod drawer_drawing_layout; 9 | mod drawer_focus; 10 | mod drawer_state; 11 | mod file_selector; 12 | mod global_view; 13 | mod help; 14 | mod help_content; 15 | mod import; 16 | mod keys; 17 | mod matched_string; 18 | mod menu; 19 | mod message; 20 | mod password_dialog; 21 | mod scroll; 22 | mod search_state; 23 | mod skin; 24 | mod status_view; 25 | mod task; 26 | mod title_view; 27 | mod view; 28 | 29 | use { 30 | crate::{ 31 | cli::Args, 32 | core::OpenCloset, 33 | error::SafeClosetError, 34 | }, 35 | crokey::crossterm::{ 36 | QueueableCommand, 37 | cursor, 38 | event::{ 39 | DisableMouseCapture, 40 | EnableMouseCapture, 41 | }, 42 | terminal::{ 43 | EnterAlternateScreen, 44 | LeaveAlternateScreen, 45 | }, 46 | }, 47 | std::{ 48 | io::Write, 49 | time::Duration, 50 | }, 51 | }; 52 | 53 | pub(crate) use { 54 | action::*, 55 | app_state::*, 56 | cmd_result::*, 57 | comments_editor::*, 58 | content_view::*, 59 | dialog::*, 60 | drawer_drawing_layout::*, 61 | drawer_focus::*, 62 | drawer_state::*, 63 | file_selector::*, 64 | global_view::*, 65 | help::*, 66 | help_content::*, 67 | import::*, 68 | keys::*, 69 | matched_string::*, 70 | menu::*, 71 | message::*, 72 | password_dialog::*, 73 | scroll::*, 74 | search_state::*, 75 | skin::*, 76 | status_view::*, 77 | task::*, 78 | title_view::*, 79 | view::*, 80 | }; 81 | 82 | pub const MAX_INACTIVITY: Duration = Duration::from_secs(120); 83 | 84 | pub trait ScreenWriter { 85 | fn go_to( 86 | &mut self, 87 | x: u16, 88 | y: u16, 89 | ) -> Result<(), SafeClosetError>; 90 | } 91 | 92 | /// the type used by all TUI writing functions 93 | pub type W = std::io::BufWriter; 94 | 95 | /// return the writer used by the application 96 | fn writer() -> W { 97 | std::io::BufWriter::new(std::io::stdout()) 98 | } 99 | 100 | impl ScreenWriter for W { 101 | fn go_to( 102 | &mut self, 103 | x: u16, 104 | y: u16, 105 | ) -> Result<(), SafeClosetError> { 106 | self.queue(cursor::MoveTo(x, y))?; 107 | Ok(()) 108 | } 109 | } 110 | 111 | pub fn run( 112 | open_closet: OpenCloset, 113 | args: &Args, 114 | ) -> Result<(), SafeClosetError> { 115 | let mut w = writer(); 116 | w.queue(EnterAlternateScreen)?; 117 | w.queue(cursor::Hide)?; 118 | w.queue(EnableMouseCapture)?; 119 | let r = app::run(&mut w, open_closet, args); 120 | w.queue(DisableMouseCapture)?; 121 | w.queue(cursor::Show)?; 122 | w.queue(LeaveAlternateScreen)?; 123 | w.flush()?; 124 | r 125 | } 126 | -------------------------------------------------------------------------------- /src/tui/password_dialog/mod.rs: -------------------------------------------------------------------------------- 1 | mod password_dialog_purpose; 2 | mod password_dialog_state; 3 | mod password_dialog_view; 4 | 5 | pub use { 6 | password_dialog_purpose::*, 7 | password_dialog_state::*, 8 | password_dialog_view::*, 9 | }; 10 | 11 | use { 12 | super::*, 13 | crokey::{ 14 | KeyCombination, 15 | crossterm::event::MouseEvent, 16 | }, 17 | }; 18 | 19 | pub struct PasswordDialog { 20 | state: PasswordDialogState, 21 | pub view: PasswordDialogView, 22 | } 23 | 24 | impl PasswordDialog { 25 | pub fn new( 26 | purpose: PasswordDialogPurpose, 27 | hide_chars: bool, 28 | ) -> Self { 29 | let state = PasswordDialogState::new(purpose, hide_chars); 30 | let view = PasswordDialogView::default(); 31 | Self { state, view } 32 | } 33 | pub fn toggle_hide_chars(&mut self) { 34 | self.state.password.password_mode ^= true; 35 | } 36 | pub fn set_hide_chars( 37 | &mut self, 38 | hide: bool, 39 | ) { 40 | self.state.password.password_mode = hide; 41 | } 42 | pub fn get_password(&self) -> String { 43 | self.state.get_password() 44 | } 45 | pub fn purpose(&self) -> PasswordDialogPurpose { 46 | self.state.purpose 47 | } 48 | pub fn apply_key_event( 49 | &mut self, 50 | key: KeyCombination, 51 | ) -> bool { 52 | self.state.apply_key_event(key) 53 | } 54 | pub fn on_mouse_event( 55 | &mut self, 56 | mouse_event: MouseEvent, 57 | double_click: bool, 58 | ) { 59 | self.state.on_mouse_event(mouse_event, double_click); 60 | } 61 | pub fn draw( 62 | &mut self, 63 | w: &mut W, 64 | app_skin: &AppSkin, 65 | ) -> Result<(), SafeClosetError> { 66 | self.view.draw(w, &mut self.state, app_skin) 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/tui/password_dialog/password_dialog_purpose.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy)] 2 | pub enum PasswordDialogPurpose { 3 | NewDrawer { depth: usize }, 4 | OpenDrawer { depth: usize }, 5 | ChangeDrawerPassword, 6 | } 7 | -------------------------------------------------------------------------------- /src/tui/password_dialog/password_dialog_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::ContentSkin, 4 | crokey::{ 5 | KeyCombination, 6 | crossterm::event::MouseEvent, 7 | }, 8 | termimad::*, 9 | }; 10 | 11 | pub struct PasswordDialogState { 12 | pub purpose: PasswordDialogPurpose, 13 | pub password: InputField, 14 | } 15 | 16 | impl PasswordDialogState { 17 | pub fn new( 18 | purpose: PasswordDialogPurpose, 19 | hide_chars: bool, 20 | ) -> Self { 21 | let mut password = ContentSkin::make_input(); 22 | password.password_mode = hide_chars; 23 | Self { purpose, password } 24 | } 25 | pub fn get_password(&self) -> String { 26 | self.password.get_content() 27 | } 28 | pub fn apply_key_event( 29 | &mut self, 30 | key: KeyCombination, 31 | ) -> bool { 32 | self.password.apply_key_combination(key) 33 | } 34 | /// handle a mouse event 35 | pub fn on_mouse_event( 36 | &mut self, 37 | mouse_event: MouseEvent, 38 | double_click: bool, 39 | ) { 40 | self.password.apply_mouse_event(mouse_event, double_click); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /src/tui/password_dialog/password_dialog_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::tui::*, 4 | termimad::*, 5 | }; 6 | 7 | #[derive(Default)] 8 | pub struct PasswordDialogView { 9 | area: Area, 10 | } 11 | 12 | static MD_CREATE_TOP_DRAWER: &str = r#"Type the passphrase for the new top level drawer:"#; 13 | static MD_CREATE_DEEP_DRAWER: &str = r#"Type the passphrase for this deep drawer (to create a top level drawer, cancel then close the drawer you're in):"#; 14 | static MD_OPEN_TOP_DRAWER: &str = r#"Type the passphrase of the shallow drawer you want to open:"#; 15 | static MD_OPEN_DEEP_DRAWER: &str = r#"Type the passphrase of the deep drawer you want to open:"#; 16 | static MD_CHANGE_PASSWORD: &str = r#"Type the new passphrase (the previous version will still be available in a '.old' backup file after you save once):"#; 17 | static MD_HIDDEN_CHARS: &str = r#"Characters are hidden. Type *^h* to toggle visibility."#; 18 | static MD_VISIBLE_CHARS: &str = r#"Characters are visible. Type *^h* to hide them."#; 19 | 20 | const INTERNAL_HEIGHT: u16 = 3 // intro: 3 21 | + 2 // pwd: 2 22 | + 3; // char hiding text: 3 23 | 24 | impl PasswordDialogView { 25 | fn introduction_text(state: &PasswordDialogState) -> &'static str { 26 | match state.purpose { 27 | PasswordDialogPurpose::NewDrawer { depth } => { 28 | if depth > 0 { 29 | MD_CREATE_DEEP_DRAWER 30 | } else { 31 | MD_CREATE_TOP_DRAWER 32 | } 33 | } 34 | PasswordDialogPurpose::OpenDrawer { depth } => { 35 | if depth > 0 { 36 | MD_OPEN_DEEP_DRAWER 37 | } else { 38 | MD_OPEN_TOP_DRAWER 39 | } 40 | } 41 | PasswordDialogPurpose::ChangeDrawerPassword => MD_CHANGE_PASSWORD, 42 | } 43 | } 44 | } 45 | 46 | impl View for PasswordDialogView { 47 | fn set_available_area( 48 | &mut self, 49 | mut area: Area, 50 | ) { 51 | if area.width > 60 && area.height > 8 { 52 | let hw = area.width / 2; 53 | let dhw = (hw * 3 / 4).min(hw - 2); 54 | area.left = hw - dhw; 55 | area.width = 2 * dhw; 56 | let h = INTERNAL_HEIGHT + 2; 57 | area.top += (area.height - h) / 3; 58 | area.height = h; 59 | } 60 | self.area = area; 61 | } 62 | 63 | /// Render the view in its area 64 | fn draw( 65 | &mut self, 66 | w: &mut W, 67 | state: &mut PasswordDialogState, // mutable to allow adapt to terminal size changes 68 | skin: &AppSkin, 69 | ) -> Result<(), SafeClosetError> { 70 | // border 71 | let border_colors = skin.dialog.md.table.compound_style.clone(); 72 | let area = &self.area; 73 | let mut rect = Rect::new(area.clone(), border_colors); 74 | rect.set_fill(true); 75 | rect.set_border_style(BORDER_STYLE_BLAND); 76 | rect.draw(w)?; 77 | 78 | // introduction 79 | let mut area = Area::new(area.left + 1, area.top + 1, area.width - 2, 3); 80 | let text = Self::introduction_text(state); 81 | skin.dialog.md.write_in_area_on(w, text, &area)?; 82 | 83 | // password input 84 | area.top += 3; 85 | state.password.change_area(area.left, area.top, area.width); 86 | state.password.display_on(w)?; 87 | 88 | // chars hiding 89 | area.top += 2; 90 | let tip = if state.password.password_mode { 91 | MD_HIDDEN_CHARS 92 | } else { 93 | MD_VISIBLE_CHARS 94 | }; 95 | skin.dialog.md.write_in_area_on(w, tip, &area)?; 96 | 97 | Ok(()) 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /src/tui/scroll.rs: -------------------------------------------------------------------------------- 1 | #[derive(Debug, Clone, Copy)] 2 | pub enum ScrollCommand { 3 | Top, 4 | Bottom, 5 | Lines(i32), 6 | Pages(i32), 7 | } 8 | 9 | #[derive(Debug, Clone, Copy)] 10 | pub enum Direction { 11 | Up, 12 | Down, 13 | } 14 | 15 | impl ScrollCommand { 16 | fn to_lines( 17 | self, 18 | content_height: usize, 19 | page_height: usize, 20 | ) -> i32 { 21 | match self { 22 | Self::Top => -(content_height as i32), 23 | Self::Bottom => content_height as i32, 24 | Self::Lines(n) => n, 25 | Self::Pages(n) => n * page_height as i32, 26 | } 27 | } 28 | /// compute the new scroll value 29 | pub fn apply( 30 | self, 31 | scroll: usize, 32 | content_height: usize, 33 | page_height: usize, 34 | ) -> usize { 35 | if content_height > page_height { 36 | (scroll as i32 + self.to_lines(content_height, page_height)) 37 | .min((content_height - page_height - 1) as i32) 38 | .max(0) as usize 39 | } else { 40 | 0 41 | } 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/tui/search_state.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::ContentSkin, 3 | crate::{ 4 | core::*, 5 | search::*, 6 | }, 7 | termimad::InputField, 8 | }; 9 | 10 | /// State of the search in a drawer 11 | pub struct SearchState { 12 | pub input: InputField, 13 | pub result: Option, 14 | } 15 | 16 | pub struct SearchResult { 17 | /// filtered entries 18 | pub entries: Vec, 19 | /// index among filtered entries of the one with the best score 20 | pub best_line: Option, 21 | } 22 | 23 | pub struct MatchingEntry { 24 | pub idx: usize, 25 | pub name_match: NameMatch, 26 | } 27 | 28 | impl Default for SearchState { 29 | fn default() -> Self { 30 | let input = ContentSkin::make_input(); 31 | Self { 32 | input, 33 | result: None, 34 | } 35 | } 36 | } 37 | 38 | impl SearchState { 39 | pub fn set_best_line( 40 | &mut self, 41 | best_line: usize, 42 | ) { 43 | if let Some(result) = self.result.as_mut() { 44 | result.best_line = Some(best_line); 45 | } 46 | } 47 | /// recompute the result from the input content 48 | pub fn update( 49 | &mut self, 50 | drawer: &OpenDrawer, 51 | ) { 52 | if self.input.is_empty() { 53 | self.result = None; 54 | } else { 55 | let pattern = FuzzyPattern::from(&self.input.get_content()); 56 | let mut entries: Vec = Vec::new(); 57 | let mut best_line: Option = None; 58 | for (idx, entry) in drawer.content.entries.iter().enumerate() { 59 | if let Some(name_match) = pattern.find(&entry.name) { 60 | if let Some(bl) = best_line { 61 | if entries[bl].name_match.score < name_match.score { 62 | best_line = Some(entries.len()); 63 | } 64 | } else { 65 | best_line = Some(entries.len()); 66 | } 67 | entries.push(MatchingEntry { idx, name_match }); 68 | } 69 | } 70 | debug!("{} matching entries", entries.len()); 71 | self.result = Some(SearchResult { entries, best_line }); 72 | } 73 | } 74 | pub fn has_content(&self) -> bool { 75 | !self.input.is_empty() 76 | } 77 | /// clear the search box 78 | pub fn clear(&mut self) { 79 | self.input.clear(); 80 | self.result = None; 81 | } 82 | } 83 | -------------------------------------------------------------------------------- /src/tui/skin/app_skin.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crokey::crossterm::style::{ 4 | Color, 5 | Color::*, 6 | }, 7 | termimad::*, 8 | }; 9 | 10 | /// Contain all the styling of the application 11 | pub struct AppSkin { 12 | pub title: MadSkin, 13 | pub help: MadSkin, 14 | pub content: ContentSkin, 15 | pub dialog: DialogSkin, 16 | pub status: StatusSkin, 17 | } 18 | 19 | impl Default for AppSkin { 20 | fn default() -> Self { 21 | let title = make_title_skin(); 22 | let help = make_help_skin(); 23 | let content = ContentSkin::default(); 24 | let dialog = DialogSkin::default(); 25 | let status = StatusSkin::default(); 26 | Self { 27 | title, 28 | help, 29 | content, 30 | dialog, 31 | status, 32 | } 33 | } 34 | } 35 | 36 | fn make_title_skin() -> MadSkin { 37 | let mut skin = MadSkin::default(); 38 | skin.paragraph.set_fgbg(AnsiValue(252), AnsiValue(239)); 39 | skin.bold.set_fg(ansi(222)); 40 | skin.italic = CompoundStyle::with_fg(AnsiValue(222)); 41 | skin.inline_code.set_bg(gray(2)); 42 | skin 43 | } 44 | 45 | fn make_help_skin() -> MadSkin { 46 | let mut help = MadSkin::default(); 47 | help.set_bg(gray(2)); 48 | help.set_fg(ansi(230)); 49 | help.set_headers_fg(ansi(222)); 50 | help.italic = CompoundStyle::with_fg(Color::AnsiValue(222)); 51 | help 52 | } 53 | -------------------------------------------------------------------------------- /src/tui/skin/content_skin.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::crossterm::style::{ 3 | Attribute, 4 | Color::*, 5 | }, 6 | termimad::{ 7 | CompoundStyle, 8 | InputField, 9 | MadSkin, 10 | ScrollBarStyle, 11 | ansi, 12 | gray, 13 | }, 14 | }; 15 | 16 | pub struct ContentSkin { 17 | normal_styles: Styles, 18 | selected_styles: Styles, 19 | faded_styles: Styles, 20 | } 21 | 22 | #[derive(Clone)] 23 | pub struct Styles { 24 | /// skin for all the markdown parts 25 | pub md: MadSkin, 26 | 27 | /// style of pattern matching chars 28 | pub char_match: CompoundStyle, 29 | } 30 | 31 | impl Default for ContentSkin { 32 | fn default() -> Self { 33 | let bg = gray(2); 34 | let mut md = MadSkin::default(); 35 | md.set_fg(ansi(230)); 36 | md.bold = CompoundStyle::new(Some(AnsiValue(222)), None, Attribute::Bold.into()); 37 | md.italic = CompoundStyle::with_fg(AnsiValue(222)); 38 | md.set_bg(bg); 39 | let char_match_fg = AnsiValue(41); 40 | let char_match = CompoundStyle::with_fgbg(char_match_fg, bg); 41 | let normal_styles = Styles { md, char_match }; 42 | 43 | let sel_bg = gray(5); 44 | let mut selected_styles = normal_styles.clone(); 45 | selected_styles.md.set_bg(sel_bg); 46 | selected_styles.md.scrollbar.thumb.set_fg(gray(10)); 47 | selected_styles.md.scrollbar.track.set_fg(gray(5)); 48 | selected_styles.char_match = CompoundStyle::with_fgbg(char_match_fg, sel_bg); 49 | 50 | let mut faded_styles = normal_styles.clone(); 51 | faded_styles.md.blend_with(bg, 0.6); 52 | 53 | Self { 54 | normal_styles, 55 | selected_styles, 56 | faded_styles, 57 | } 58 | } 59 | } 60 | 61 | impl ContentSkin { 62 | /// build an input field with the application's skin 63 | pub fn make_input() -> InputField { 64 | let mut input_field = InputField::default(); 65 | input_field.set_normal_style(CompoundStyle::with_fgbg(ansi(230), gray(0))); 66 | input_field 67 | } 68 | pub fn styles( 69 | &self, 70 | selected: bool, 71 | faded: bool, 72 | ) -> &Styles { 73 | if faded { 74 | &self.faded_styles 75 | } else if selected { 76 | &self.selected_styles 77 | } else { 78 | &self.normal_styles 79 | } 80 | } 81 | pub fn match_style( 82 | &self, 83 | selected: bool, 84 | faded: bool, 85 | ) -> &CompoundStyle { 86 | &self.styles(selected, faded).char_match 87 | } 88 | pub fn txt_style( 89 | &self, 90 | selected: bool, 91 | faded: bool, 92 | ) -> &CompoundStyle { 93 | &self.styles(selected, faded).md.paragraph.compound_style 94 | } 95 | pub fn tbl_style( 96 | &self, 97 | selected: bool, 98 | faded: bool, 99 | ) -> &CompoundStyle { 100 | &self.styles(selected, faded).md.table.compound_style 101 | } 102 | pub fn scrollbar_style( 103 | &self, 104 | selected: bool, 105 | faded: bool, 106 | ) -> &ScrollBarStyle { 107 | &self.styles(selected, faded).md.scrollbar 108 | } 109 | } 110 | -------------------------------------------------------------------------------- /src/tui/skin/dialog_skin.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::crossterm::style::Color, 3 | termimad::*, 4 | }; 5 | 6 | /// The skin for menus and dialogs 7 | pub struct DialogSkin { 8 | pub md: MadSkin, 9 | pub sel_md: MadSkin, 10 | } 11 | 12 | impl Default for DialogSkin { 13 | fn default() -> Self { 14 | let mut md = MadSkin { 15 | italic: CompoundStyle::with_fg(Color::AnsiValue(222)), 16 | ..Default::default() 17 | }; 18 | md.set_bg(gray(4)); 19 | let mut sel_md = md.clone(); 20 | sel_md.set_bg(gray(8)); 21 | Self { md, sel_md } 22 | } 23 | } 24 | -------------------------------------------------------------------------------- /src/tui/skin/mod.rs: -------------------------------------------------------------------------------- 1 | mod app_skin; 2 | mod content_skin; 3 | mod dialog_skin; 4 | mod status_skin; 5 | 6 | pub use { 7 | app_skin::*, 8 | content_skin::*, 9 | dialog_skin::*, 10 | status_skin::*, 11 | }; 12 | -------------------------------------------------------------------------------- /src/tui/skin/status_skin.rs: -------------------------------------------------------------------------------- 1 | use { 2 | crokey::crossterm::style::Color::*, 3 | termimad::{ 4 | CompoundStyle, 5 | MadSkin, 6 | ansi, 7 | gray, 8 | }, 9 | }; 10 | 11 | pub struct StatusSkin { 12 | pub hint: MadSkin, 13 | pub info: MadSkin, 14 | pub task: MadSkin, 15 | pub error: MadSkin, 16 | } 17 | 18 | impl Default for StatusSkin { 19 | fn default() -> Self { 20 | let mut hint = MadSkin::default(); 21 | hint.paragraph.set_fgbg(AnsiValue(252), AnsiValue(239)); 22 | hint.italic = CompoundStyle::with_fg(AnsiValue(222)); 23 | let mut info = MadSkin::default(); 24 | info.paragraph.set_fg(AnsiValue(252)); 25 | info.italic = CompoundStyle::with_fg(AnsiValue(222)); 26 | info.set_bg(ansi(24)); 27 | let mut task = MadSkin::default(); 28 | task.paragraph.set_fg(gray(1)); 29 | task.set_bg(ansi(222)); 30 | let mut error = MadSkin::default(); 31 | error.paragraph.set_fgbg(AnsiValue(254), AnsiValue(160)); 32 | Self { 33 | hint, 34 | info, 35 | task, 36 | error, 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /src/tui/status_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::error::SafeClosetError, 4 | termimad::{ 5 | Area, 6 | minimad::{ 7 | Alignment, 8 | Composite, 9 | }, 10 | }, 11 | }; 12 | 13 | /// The view giving hints or informing of an error, at 14 | /// the bottom of the screen 15 | #[derive(Default)] 16 | pub struct StatusView { 17 | area: Area, 18 | drawer_display_count: usize, 19 | } 20 | 21 | impl StatusView { 22 | /// return a hint for when a drawer is displayed 23 | fn rotate_drawer_hint( 24 | &mut self, 25 | ds: &DrawerState, 26 | ) -> &'static str { 27 | use DrawerFocus::*; 28 | let mut hints: Vec<&'static str> = Vec::new(); 29 | match &ds.focus { 30 | NoneSelected if !ds.drawer.content.entries.is_empty() => { 31 | if ds.search.has_content() { 32 | hints.push("Hit */* then *esc* to clear the search"); 33 | } 34 | hints.push("Hit *^q* to quit, */* to search, *^h* to toggle values visibility"); 35 | hints.push("Hit *^q* to save, */* to search, arrows to select a cell"); 36 | hints.push("Hit *^q* to quit, *tab* or *n* to create a new entry"); 37 | hints.push("Hit *^s* to save, *^q* to quit, *?* for help"); 38 | } 39 | NoneSelected => { 40 | hints.push("Hit *^q* to quit, *tab* or *n* to create a new entry"); 41 | } 42 | NameSelected { .. } | ValueSelected { .. } => { 43 | if ds.search.has_content() { 44 | hints.push("Hit */* then *esc* to clear the search"); 45 | } 46 | hints.push("Hit *^q* to quit, *i* to edit the selected cell, *?* for help"); 47 | hints.push("Hit *^q* to quit, *i* to edit the selected cell, *esc* for menu"); 48 | hints 49 | .push("Hit *^q* to quit, *i* or *a* to edit the selected cell, *esc* for menu"); 50 | hints.push("Hit *^q* to quit, */* to search, *n* to create a new entry"); 51 | hints.push("Hit *^q* to quit, */* to search, *^h* to toggle values visibility"); 52 | hints.push("Hit *^q* to save, */* to search, arrows to select a cell"); 53 | hints.push("Hit *^q* to quit, *tab* to edit the next cell"); 54 | hints.push("Hit *^s* to save, *^q* to quit, *?* for help"); 55 | } 56 | SearchEdit { .. } => { 57 | if ds.search.input.is_empty() { 58 | hints.push("Hit *esc* to cancel search, or a few chars to filter entries"); 59 | } else if ds.search.has_content() { 60 | hints.push("Hit *esc* to cancel search, *enter* to keep the result"); 61 | hints.push( 62 | "Hit *esc* to cancel search, arrows to keep the result and move selection", 63 | ); 64 | } else { 65 | hints.push("Hit *esc* to cancel search"); 66 | } 67 | } 68 | NameEdit { .. } | ValueEdit { .. } => { 69 | hints.push("Hit *esc* to cancel edition, *enter* to validate"); 70 | hints.push("Hit *tab* to validate and go to next field"); 71 | } 72 | PendingRemoval { .. } => { 73 | hints.push("Hit *y* to confirm entry removal (any other key cancels it)"); 74 | } 75 | } 76 | if ds.touched() { 77 | hints.push("Hit *^s* to save, *^q* to quit, *esc* for menu"); 78 | } else { 79 | hints.push("Hit *^q* to quit, *esc* for menu"); 80 | } 81 | let idx = (self.drawer_display_count / 3) % hints.len(); 82 | self.drawer_display_count += 1; 83 | hints[idx] 84 | } 85 | } 86 | 87 | impl View for StatusView { 88 | fn set_available_area( 89 | &mut self, 90 | area: Area, 91 | ) { 92 | self.area = area; 93 | } 94 | 95 | fn draw( 96 | &mut self, 97 | w: &mut W, 98 | state: &mut AppState, 99 | app_skin: &AppSkin, 100 | ) -> Result<(), SafeClosetError> { 101 | w.go_to(self.area.left, self.area.top)?; 102 | let skin; 103 | let text; 104 | if let Some(task) = state.pending_tasks.first() { 105 | text = task.label(); 106 | skin = &app_skin.status.task; 107 | } else if let Some(ref message) = &state.message { 108 | skin = if message.error { 109 | &app_skin.status.error 110 | } else { 111 | &app_skin.status.info 112 | }; 113 | text = &message.text; 114 | } else { 115 | text = match &state.dialog { 116 | Dialog::None => { 117 | if let Some(ds) = &state.drawer_state { 118 | self.rotate_drawer_hint(ds) 119 | } else { 120 | "Hit *^q* to quit, *?* for help" 121 | } 122 | } 123 | Dialog::Menu(_) => { 124 | "Hit arrows to select an item, *enter* to validate, *esc* to close" 125 | } 126 | Dialog::Help(_) => "Hit *^q* to quit, *esc* to close the help", 127 | Dialog::Password(_) => "Hit *esc* to cancel, *enter* to validate, *^q* to quit", 128 | Dialog::CommentsEditor(_) => { 129 | "Hit *esc* to cancel, *enter* to validate, *^q* to quit" 130 | } 131 | Dialog::Import(import) => import.status(), 132 | }; 133 | skin = &app_skin.status.hint; 134 | } 135 | let composite = Composite::from_inline(text); 136 | skin.write_composite_fill( 137 | w, 138 | composite, 139 | self.area.width as usize, 140 | Alignment::Unspecified, 141 | )?; 142 | Ok(()) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/tui/task.rs: -------------------------------------------------------------------------------- 1 | /// a potentially long task, which is queued before execution 2 | pub enum Task { 3 | Save, 4 | CreateDrawer(String), 5 | OpenDrawer(String), 6 | CloseDrawer, 7 | ChangePassword(String), 8 | } 9 | 10 | impl Task { 11 | /// return the label to display during task execution 12 | pub fn label(&self) -> &'static str { 13 | match self { 14 | Self::Save => "Saving...", 15 | Self::CreateDrawer(_) => "Creating a drawer...", 16 | Self::OpenDrawer(_) => "Opening...", 17 | Self::CloseDrawer => "Closing...", 18 | Self::ChangePassword(_) => "Changing password...", 19 | } 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /src/tui/title_view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::error::SafeClosetError, 4 | termimad::{ 5 | Area, 6 | minimad::{ 7 | Alignment, 8 | Composite, 9 | }, 10 | }, 11 | }; 12 | 13 | #[derive(Default)] 14 | pub struct TitleView { 15 | area: Area, 16 | } 17 | 18 | impl View for TitleView { 19 | fn set_available_area( 20 | &mut self, 21 | area: Area, 22 | ) { 23 | self.area = area; 24 | } 25 | fn draw( 26 | &mut self, 27 | w: &mut W, 28 | state: &mut AppState, 29 | app_skin: &AppSkin, 30 | ) -> Result<(), SafeClosetError> { 31 | let path = state.open_closet.path().to_string_lossy(); 32 | let md = format!(" **SafeCloset** ` ` {} ` ` {} ", &path, state_info(state)); 33 | let composite = Composite::from_inline(&md); 34 | w.go_to(self.area.left, self.area.top)?; 35 | let width = self.area.width as usize; 36 | app_skin 37 | .title 38 | .write_composite_fill(w, composite, width, Alignment::Unspecified)?; 39 | Ok(()) 40 | } 41 | } 42 | 43 | fn state_info(state: &AppState) -> &'static str { 44 | match &state.drawer_state { 45 | None => { 46 | if state.open_closet.just_created() && state.created_drawers == 0 { 47 | "new closet" 48 | } else { 49 | "no open drawer" 50 | } 51 | } 52 | Some(ds) => { 53 | if ds.touched() { 54 | "*unsaved changes*" 55 | } else { 56 | "" 57 | } 58 | } 59 | } 60 | } 61 | -------------------------------------------------------------------------------- /src/tui/view.rs: -------------------------------------------------------------------------------- 1 | use { 2 | super::*, 3 | crate::error::SafeClosetError, 4 | termimad::Area, 5 | }; 6 | 7 | /// A part of the screen. 8 | /// 9 | /// Note that this isn't a general purpose TUI framework, it's only 10 | /// suitable for this application 11 | pub trait View: Default { 12 | /// set the outside area. This view may take it wholly or partially 13 | fn set_available_area( 14 | &mut self, 15 | area: Area, 16 | ); 17 | 18 | /// Render the view in its area 19 | fn draw( 20 | &mut self, 21 | w: &mut W, 22 | state: &mut State, // mutable to allow adapt to terminal size changes 23 | app_skin: &AppSkin, 24 | ) -> Result<(), SafeClosetError>; 25 | } 26 | -------------------------------------------------------------------------------- /version.sh: -------------------------------------------------------------------------------- 1 | # extract the version from the Cargo.toml file 2 | version=$(sed 's/^version = "\([^\"]*\)"/\1/;t;d' Cargo.toml | head -1) 3 | 4 | echo "$version" 5 | -------------------------------------------------------------------------------- /website/.gitignore: -------------------------------------------------------------------------------- 1 | site 2 | -------------------------------------------------------------------------------- /website/README.md: -------------------------------------------------------------------------------- 1 | 2 | SafeCloset's website is live at https://dystroy.org/safecloset 3 | 4 | It's built using [mkdocs](https://www.mkdocs.org/) (minimal version: 1.0.4). 5 | 6 | To test it locally, cd to the website directory then 7 | 8 | mkdocs serve 9 | 10 | To build it, do 11 | 12 | mkdocs build 13 | 14 | -------------------------------------------------------------------------------- /website/custom_theme/main.html: -------------------------------------------------------------------------------- 1 | {% extends "base.html" %} 2 | 3 | {% block footer %} 4 | {% endblock %} 5 | -------------------------------------------------------------------------------- /website/custom_theme/toc.html: -------------------------------------------------------------------------------- 1 | 22 | -------------------------------------------------------------------------------- /website/deploy.sh: -------------------------------------------------------------------------------- 1 | mkdocs build 2 | cp -r site/* ~/dev/www/dystroy/safecloset/ 3 | 4 | # deploy on dystroy.org 5 | ~/dev/www/dystroy/deploy.sh 6 | 7 | 8 | -------------------------------------------------------------------------------- /website/docs/README.md: -------------------------------------------------------------------------------- 1 | 2 | 3 | This directory is the source of the web site which is at https://dystroy.org/safecloset 4 | -------------------------------------------------------------------------------- /website/docs/community.md: -------------------------------------------------------------------------------- 1 | 2 | # Sponsorship 3 | 4 | **SafeCloset** is developed by [Denys Séguret](https://twitter.com/DenysSeguret), also known as [Canop](https://github.com/Canop) or [dystroy](https://dystroy.org). 5 | 6 | **SafeCloset** is free for everybody, and everbody's welcome to ask for advice or new features. But if you *want* to help me find time to improve it, and to develop [other free programs](https://dystroy.org), consider [sponsoring me](https://github.com/sponsors/Canop). There's absolutely no moral obligation to do so, though. 7 | 8 | # Chat 9 | 10 | The best place to chat about safecloset, to talk about features or bugs, is the Miaou chat. 11 | 12 | * [Rust & SafeCloset room on Miaou](https://miaou.dystroy.org/3490?rust) 13 | * [French Programmers room on Miaou](https://miaou.dystroy.org/3) 14 | 15 | # Issues 16 | 17 | We use [GitHub's issue manager](https://github.com/Canop/safecloset/issues). 18 | 19 | Before posting a new issue, check your problem hasn't already been raised and in case of doubt **please come first discuss it on the chat**. 20 | 21 | # Independant security audit 22 | 23 | None has been done yet. 24 | I'd welcome help on this topic. 25 | 26 | # Storage format 27 | 28 | The storage format is described to ensure it's possible to replace SafeCloset with another software if needed. 29 | 30 | The closet file is a [MessagePack](https://msgpack.org/index.html) encoded structure `Closet` with the following fields: 31 | 32 | * `comments`: a string 33 | * `salt`: a string 34 | * `drawers`: an array of `ClosedDrawer` 35 | 36 | The MessagePack serialization preserves field names and allows future additions. 37 | 38 | An instance of `ClosedDrawer` is a structure with the following fields: 39 | 40 | * `id`: a byte array 41 | * `nonce`: a byte array 42 | * `content`: a byte array 43 | 44 | The `content` is the AES-GCM-SIV encryption of the serialized drawer with the included `nonce`. 45 | The key used for this encryption is a 256 bits Argon2 hash of the password with the closet's salt. 46 | 47 | The serialized drawer is a MessagePack encoded structure with the following fields: 48 | 49 | * `id`: a byte array 50 | * `entries`: an array of `Entry` 51 | * `settings`: an instance of `DrawerSettings` 52 | * `closet`: a deeper closet, containing drawers, etc. 53 | * `garbage`: a random byte array 54 | 55 | Instances of `Entry` contain the following fields: 56 | 57 | * `name`: a string 58 | * `value`: a string 59 | 60 | Instances of `DrawerSettings` contain the following fields: 61 | 62 | * `hide_values`: a boolean 63 | * `open_all_values`: a boolean (optional, false if not present) 64 | 65 | -------------------------------------------------------------------------------- /website/docs/css/extra.css: -------------------------------------------------------------------------------- 1 | /********************/ 2 | /* standard classes */ 3 | 4 | :root { 5 | --text: #333; 6 | --strong-text: #111; 7 | --accent: #d43847; // #b52222; 8 | --dark-accent: var(--accent); 9 | --bg: #eee; 10 | --kbd-fg: #fff; 11 | --kbd-bg: var(--accent); 12 | --sidenav-bg: #b52222; 13 | --top-nav-bg: var(--sidenav-bg); 14 | --sidenav-fg: #fff; 15 | --link-fg: #000; 16 | --code-fg: #111; 17 | --code-bg: #fafafa; 18 | --inline-code-bg: var(--code-bg); 19 | --inline-code-border: none; 20 | --hovered-menu-bg: var(--bg); 21 | --hovered-menu-fg: var(--text); 22 | } 23 | 24 | body::before { 25 | background: 0; 26 | } 27 | 28 | body { 29 | font-size: 17px; 30 | background-color: var(--bg); 31 | color: var(--text); 32 | } 33 | body .modal-content { 34 | background: var(--accent); 35 | color: #eee; 36 | } 37 | .modal-dialog { 38 | font-size: 15px; 39 | color: #ccc; 40 | } 41 | .modal-dialog a { 42 | color: #fff; 43 | } 44 | .modal-dialog a:hover { 45 | text-decoration: underline; 46 | color: #fff; 47 | } 48 | 49 | body .bg-secondary { 50 | background-color: transparent !important; 51 | } 52 | body .card { 53 | border: none; 54 | } 55 | 56 | .sponsorship { 57 | margin-top: 20px; 58 | display: flex; 59 | flex-direction: row; 60 | 61 | } 62 | .sponsorship a { 63 | margin-right: 10px; 64 | } 65 | 66 | p.logo { 67 | text-align: center; 68 | } 69 | .col-md-9 p.logo img.logo { 70 | background: transparent; 71 | box-shadow: none; 72 | } 73 | 74 | .container h1 { 75 | font-size: 22px; 76 | border-bottom: solid 1px; 77 | padding-bottom: 8px; 78 | margin: 40px 0 25px 0; 79 | border-image-source: linear-gradient(to right, var(--accent), #8880); 80 | border-image-slice: 1; 81 | color: var(--strong-text); 82 | } 83 | .modal-body h2 { 84 | font-size: 18px; 85 | } 86 | .container h2 { 87 | font-size: 20px; 88 | color: var(--strong-text); 89 | margin: 25px 0 20px 0; 90 | } 91 | .modal-body h3 { 92 | font-size: 14px; 93 | } 94 | .container h3 { 95 | font-size: 16px; 96 | color: var(--strong-text); 97 | } 98 | .modal-body h4, .modal-dialog h4 { 99 | font-size: 16px; 100 | color: #ddd; 101 | } 102 | .container h4 { 103 | color: var(--strong-text); 104 | } 105 | 106 | body .navbar { 107 | background: var(--top-nav-bg) !important; 108 | padding: 0; 109 | } 110 | 111 | html body .navbar-default .navbar-nav > .active > a.nav-link, 112 | html body .navbar-default .navbar-nav > .active > a.nav-link:focus, 113 | html body .navbar .navbar-nav > .active > a.nav-link, 114 | html body .navbar .navbar-nav > .active > a.nav-link:focus { 115 | background: var(--bg); 116 | color: var(--strong-text); 117 | } 118 | .navbar-default .navbar-nav > .open > a, 119 | .navbar .navbar-nav > .open > a, 120 | .navbar-default .navbar-nav > .open > a:hover, 121 | .navbar .navbar-nav > .open > a:hover, 122 | .navbar-default .navbar-nav > .open > a:focus, 123 | .navbar .navbar-nav > .open > a:focus, 124 | .navbar-default .navbar-nav > li > a:hover, 125 | .navbar .navbar-nav > li > a:hover, 126 | .navbar-default .navbar-nav > li > a:hover, 127 | .navbar .navbar-nav > li > a:hover, 128 | .navbar-default .navbar-nav > li > a:focus, 129 | .navbar .navbar-nav > li > a:focus { 130 | background: var(--hovered-menu-bg); 131 | color: var(--hovered-menu-fg); 132 | } 133 | 134 | body .navbar-dark .navbar-nav .nav-link:hover, body .navbar-dark .navbar-nav .nav-link:focus { 135 | background: var(--hovered-menu-bg); 136 | color: var(--strong-text); 137 | } 138 | 139 | pre { 140 | padding: 0; 141 | color: var(--code-fg); 142 | background: var(--code-bg); 143 | border: solid 1px var(--code-bg); 144 | } 145 | 146 | pre code { 147 | padding: 6px 0 4px 6px; 148 | } 149 | 150 | code { 151 | border-radius: 0; 152 | color: var(--code-fg); 153 | border: var(--inline-code-border); 154 | background: var(--inline-code-bg); 155 | } 156 | .hljs { 157 | color: var(--code-fg); 158 | background: var(--code-bg); 159 | } 160 | 161 | body a, 162 | body a.nav-link, 163 | body .navbar-dark .navbar-nav a, 164 | body .navbar-dark .navbar-nav a.nav-link 165 | { 166 | color: var(--sidenav-fg); 167 | } 168 | body li.nav-item a:hover, 169 | body a.nav-link:hover 170 | { 171 | color: var(--hovered-menu-fg); 172 | background: var(--bg); 173 | } 174 | [role="main"] a { 175 | text-decoration: underline; 176 | color: var(--link-fg); 177 | } 178 | [role="main"] a:hover { 179 | background: var(--accent); 180 | } 181 | 182 | kbd { 183 | padding: 2px 4px; 184 | color: var(--kbd-fg); 185 | background: var(--kbd-bg); 186 | border-radius: 2px; 187 | box-shadow: none; 188 | font-weight: bold; 189 | margin: 1px; 190 | } 191 | 192 | kbd.b { 193 | font-size: 18px; 194 | padding: 0 5px; 195 | } 196 | 197 | .col-md-9 { 198 | max-width: 1000px; 199 | } 200 | .col-md-9 img { 201 | max-width: 100%; 202 | display: inline-block; 203 | padding: 0; 204 | line-height: 1.428571429; 205 | background-color: #fff; 206 | border: none; 207 | border-radius: 0; 208 | margin: 10px auto 20px auto; 209 | box-shadow: 2px 2px 5px rgba(0,0,0,0.7); 210 | } 211 | .navbar-collapse { 212 | padding-left: 0; 213 | } 214 | .bs-sidenav { 215 | background: var(--sidenav-bg); 216 | } 217 | .bs-sidenav.nav li { 218 | padding-left: 15px; 219 | } 220 | .bs-sidenav.nav li.main { 221 | padding-left: 0px; 222 | } 223 | body .navbar { 224 | line-height: 36px; 225 | } 226 | body .navbar-brand { 227 | background: url(../img/logo-var(--bg).svg) no-repeat; 228 | background-position: 5px center; 229 | background-size: 70px 40px; 230 | padding-left: 85px; 231 | font-size: 18px; 232 | line-height: 24px; 233 | } 234 | 235 | .bs-sidebar .nav > li > a { 236 | } 237 | .bs-sidebar .nav > .active > a, 238 | .bs-sidebar .nav > .active:hover > a, 239 | .bs-sidebar .nav > .active:focus > a, 240 | .bs-sidebar .nav > li > a.active, 241 | .bs-sidebar .nav > li > a.active:hover, 242 | .bs-sidebar .nav > li > a.active:focus 243 | { 244 | font-weight: initial; 245 | background-color: var(--bg); 246 | color: var(--strong-text); 247 | } 248 | .bs-sidebar .nav > .main.active > a, 249 | .bs-sidebar .nav > .main.active:hover > a, 250 | .bs-sidebar .nav > .main.active:focus > a { 251 | font-weight: normal; 252 | } 253 | .bs-sidebar .nav > li > a { 254 | font-weight: normal; 255 | } 256 | .bs-sidebar .nav > li > a:hover, .bs-sidebar .nav > li > a:focus, 257 | .bs-sidebar .nav > li > a.active, .bs-sidebar .nav > li > a.active:hover, .bs-sidebar .nav > li > a.active:focus { 258 | border: none; 259 | } 260 | .table-striped > tbody > tr:nth-of-type(2n+1) { 261 | background: transparent; 262 | } 263 | .table > thead > tr > th, .table > tbody > tr > th, .table > tfoot > tr > th, .table > thead > tr > td, .table > tbody > tr > td, .table > tfoot > tr > td { 264 | border-top: 1px solid var(--dark-accent); 265 | } 266 | .table-hover > tbody > tr:hover { 267 | background: transparent; 268 | } 269 | table td a { 270 | color: var(--dark-accent); 271 | } 272 | .table > thead > tr > th { 273 | border-bottom: 2px solid var(--dark-accent); 274 | } 275 | 276 | body .dropdown-menu { 277 | background: #222; 278 | } 279 | body .dropdown-menu li a { 280 | color: #ddd; 281 | } 282 | 283 | body .dropdown-menu > .active > a, 284 | body .dropdown-menu > .active > a:focus { 285 | color: #111; 286 | background: #fc0; 287 | } 288 | body ul.dropdown-menu li.active a:hover { 289 | color: #111; 290 | background: #fc0; 291 | /**color: #ff5f87; 292 | background: #fc0;**/ 293 | } 294 | body ul.dropdown-menu li a:hover { 295 | color: #111; 296 | background: #fc0; 297 | /**background: #111; 298 | color: #ff5f87;**/ 299 | } 300 | body .toc-header { 301 | font-weight: bold; 302 | font-size: 18px; 303 | margin-left: 20px; 304 | padding-bottom: 8px; 305 | align-self: start; 306 | /*border-bottom: thin solid #ff5f87;*/ 307 | } 308 | .admonition.note { 309 | color: #222; 310 | background-color: #1c393542; 311 | border-color: #144633; 312 | } 313 | 314 | td code { 315 | var(--bg)-space: nowrap; 316 | } 317 | 318 | .modal-content { 319 | background: #222; 320 | } 321 | 322 | code.no-wrap { 323 | var(--bg)-space: normal; 324 | } 325 | 326 | 327 | body #toc-collapse ul.nav.flex-column.bs-sidenav { 328 | overflow-y: auto; 329 | max-height: 85vh; 330 | } 331 | /******************/ 332 | /* custom classes */ 333 | 334 | .install-link { 335 | font-size: 17px; 336 | margin: 10px 0 10px 0px; 337 | text-align:center; 338 | background: #133d55; 339 | border-radius: 5px; 340 | padding: 5px 8px; 341 | } 342 | .install-link:hover { 343 | background: #acf; 344 | } 345 | -------------------------------------------------------------------------------- /website/docs/css/link-to-dystroy.css: -------------------------------------------------------------------------------- 1 | 2 | .link-to-dystroy { 3 | position: absolute; 4 | left: 4px; 5 | top: 0; 6 | height: 100%; 7 | width: 50px; 8 | background: url(../img/dystroy-rust-white.svg) no-repeat; 9 | background-position: left center; 10 | background-size: 40px 40px; 11 | opacity: .3; 12 | transition: opacity .5s; 13 | } 14 | .link-to-dystroy:hover { 15 | opacity: 1; 16 | transition: opacity .5s; 17 | } 18 | -------------------------------------------------------------------------------- /website/docs/features.md: -------------------------------------------------------------------------------- 1 | 2 | # Secure design 3 | 4 | * The closet contains several drawers, some of them automatically created with an unknown password so that nobody can determine which drawers you're able to open, or even how many 5 | * Each drawer is separately crypted with AES-GCM-SIV, with a random one-use nonce and the password/key of your choice. This gives an inherently long to test decrypt algorithm (but you should still use long passphrases for your drawers) 6 | * You can have one or several drawers with real content. You can be forced to open a drawer at gun point and still keep other drawers secret without any trace, either at the top level or deeper in the drawer you opened 7 | * When you open a drawer, with its password, you can read it, search it, edit it, close it 8 | * In an open drawer you can create new drawers, or open deeper drawers if you know their password 9 | * SafeCloset automatically quits on inactivity 10 | * The size of the drawer's content isn't observable 11 | * If you edit a drawer, an attacker storing all versions of the closet wouldn't know if you edited a deeper drawer or not 12 | * No clear file is ever created, edition is done directly in the TUI (external editors are usually the weakest point) 13 | * No clear data is ever given to any external library, widget, etc. 14 | * All data is viewed and edited in the TUI application 15 | * You can compile SafeCloset yourself. Its code is small and auditable 16 | * The code is 100% in Rust. I wouldn't trust anything else today for such a program 17 | * The format of the closet file is described so that another application could be written to decode your closet files in the future (assuming you have the password) 18 | * SafeCloset can't be queried by other applications, like browsers. This is a feature. 19 | * You may have all your secrets in one file wich is easy to keep with you and to backup 20 | * No company can die and lose your secrets: you keep everything, with as many copies as necessary, where you want 21 | * No company can be forced to add some secret stealing code: SafeCloset is small, open-source and repleacable 22 | * Cross-platform because you don't know where you'll have to use your closet, and you don't know what OS you'll use 20 years from now 23 | * "I'm being watched" mode in which unselected values are hidden. This mode is kept per drawer, always activated when you launch SafeCloset with the `--hide` option, and toggled with ctrlh 24 | 25 | 26 | # Cross-platform 27 | 28 | Because you don't know where you'll need your files, SafeCloset is written for 29 | 30 | * Linux (with several variants) 31 | * Mac 32 | * Windows (a recent enough terminal is needed) 33 | 34 | # Convenience 35 | 36 | SafeCloset is designed to allow very fast sessions, adding only a few keystrokes over the passphrase typing. 37 | 38 | See the [most typical sessions](../usage#most-typical-sessions). 39 | -------------------------------------------------------------------------------- /website/docs/img/clear-drawer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/clear-drawer.png -------------------------------------------------------------------------------- /website/docs/img/drawer-creation.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/drawer-creation.png -------------------------------------------------------------------------------- /website/docs/img/drawer-opening.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/drawer-opening.png -------------------------------------------------------------------------------- /website/docs/img/dystroy-rust-white.svg: -------------------------------------------------------------------------------- 1 | 2 | -------------------------------------------------------------------------------- /website/docs/img/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/favicon.ico -------------------------------------------------------------------------------- /website/docs/img/favicon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/favicon.png -------------------------------------------------------------------------------- /website/docs/img/help.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/help.png -------------------------------------------------------------------------------- /website/docs/img/logo-safecloset.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/logo-safecloset.png -------------------------------------------------------------------------------- /website/docs/img/lyon.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/lyon.jpg -------------------------------------------------------------------------------- /website/docs/img/menu.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/menu.png -------------------------------------------------------------------------------- /website/docs/img/multiline.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/multiline.png -------------------------------------------------------------------------------- /website/docs/img/new-closet.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/new-closet.png -------------------------------------------------------------------------------- /website/docs/img/new-drawer.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/new-drawer.png -------------------------------------------------------------------------------- /website/docs/img/search-ca.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/search-ca.png -------------------------------------------------------------------------------- /website/docs/img/search.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/search.png -------------------------------------------------------------------------------- /website/docs/img/typing-entry.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/Canop/safecloset/f386a5e5091b0b6d4fc5d743caedce0abdecab74/website/docs/img/typing-entry.png -------------------------------------------------------------------------------- /website/docs/index.md: -------------------------------------------------------------------------------- 1 | 2 | # Purpose 3 | 4 | **SafeCloset** is a **secure**, **cross-platform**, and **convenient** secret holder, running as a terminal application without interprocess communication nor temporary files. 5 | 6 | It stores your secrets in a **closet** file you can publicly backup, keep with you on an USB key, etc. 7 | 8 | A closet contains **drawers**, each one is found and open with its own password. 9 | 10 | A drawer contains a list of entries (each one being a key and a value). 11 | Values are texts in which you can store a code, a password, comments, a poem, some data, etc. 12 | 13 | A drawer can also contain deeper crypted drawers. 14 | 15 | # Usage Overview 16 | 17 | *Those screenshots are small, to fit here, but you may use SafeCloset full screen if you want.* 18 | 19 | ## Create your closet file 20 | 21 | Run 22 | 23 | ```bash 24 | safecloset some/name.closet 25 | ``` 26 | 27 | ![new closet](img/new-closet.png) 28 | 29 | ## Have a glance at the help 30 | 31 | Hit ? to go to the help screen, where you'll find the complete list of commands. 32 | 33 | ![help](img/help.png) 34 | 35 | Hit esc to get back to the previous screen. 36 | 37 | ## Create your first drawer 38 | 39 | Hit ctrln 40 | 41 | ![drawer creation](img/drawer-creation.png) 42 | 43 | ![new drawer](img/new-drawer.png) 44 | 45 | If you want, you can create a deeper drawer there, at any time, by hitting ctrln. 46 | 47 | Or hit n to create a new entry, starting with its name then hitting tab to go fill its value. 48 | 49 | ![typing entry](img/typing-entry.png) 50 | 51 | Change the selection with the arrow keys. 52 | Go from input to input with the tab key. Or edit the currently selected field with a. 53 | 54 | Reorder entries with ctrl🠕 and ctrl🠗. 55 | 56 | In SafeCloset, when editing, searching, opening, etc., the enter key validates the operation while the esc key cancels or closes. 57 | 58 | You may add newlines in values with ctrlenter or altenter: 59 | 60 | ![multiline](img/multiline.png) 61 | 62 | *You may notice the values are rendered as Markdown. This is opt-in, with a toggle in the drawer's menu.* 63 | 64 | Don't hesitate to store hundreds of secrets in the same drawer as you'll easily find them with the fuzzy search. 65 | 66 | Search with the / key: 67 | 68 | ![search](img/search.png) 69 | 70 | When in the search input, remove the search with esc, freeze it with enter. 71 | 72 | ## Check the menu 73 | 74 | The menu opens on a hit on esc. It features the essential commands and their shortcuts: 75 | 76 | ![menu](img/menu.png) 77 | 78 | ## Save and quit 79 | 80 | Hit ctrls to save, then ctrlq to quit. 81 | 82 | ## Reopen 83 | 84 | The same command is used later on to open the closet again: 85 | 86 | ```bash 87 | safecloset some/name.closet 88 | ``` 89 | 90 | It may be a good idea to define an alias so that you have your secrets easily available. 91 | You could for example have this in you `.bashrc`: 92 | 93 | ```bash 94 | function xx { 95 | safecloset -o ~/some/name.closet 96 | } 97 | ``` 98 | 99 | The `-o` argument makes safecloset immediately prompt for drawer password, so that you don't have to type ctrlo. 100 | 101 | On opening, just type the password of the drawer you want to open (all will be tested until the right one opens): 102 | 103 | ![drawer opening](img/drawer-opening.png) 104 | 105 | # Warning 106 | 107 | **SafeCloset** hasn't been independently audited yet and comes with **absolutely** no guarantee. 108 | And I can do nothing for you if you lose the secrets you stored in SafeCloset. 109 | 110 | -------------------------------------------------------------------------------- /website/docs/install.md: -------------------------------------------------------------------------------- 1 | 2 | The current version of SafeCloset works on linux, Mac, Windows, and Android (over Termux). 3 | 4 | # From source 5 | 6 | You'll need to have the [Rust development environment](https://www.rustup.rs) installed and up to date. 7 | 8 | Fetch the [Canop/safecloset](https://github.com/Canop/safecloset) repository, move to the safecloset directory, then run 9 | 10 | ```bash 11 | cargo install --locked --path . 12 | ``` 13 | 14 | # From precompiled binaries 15 | 16 | Binaries are made available at every release on [GitHub](https://github.com/Canop/safecloset/releases). 17 | 18 | When you download executable files, you'll have to ensure the shell can find them. An easy solution is to put them in `/usr/local/bin`. 19 | 20 | You may also have to set them executable on linux using `chmod +x safecloset`. 21 | 22 | # From crates.io 23 | 24 | You'll need to have the [Rust development environment](https://www.rustup.rs) installed and up to date. 25 | 26 | Once it's installed, use cargo to install safecloset: 27 | 28 | cargo install --locked safecloset 29 | 30 | # FAQ 31 | 32 | *(if you encountered a problem and solved it, please tell me so that we can help other users)* 33 | 34 | ## Why `--locked` 35 | 36 | This forces the dependencies to be in the same version than when I released. 37 | This protects against some possible attacks on the dependency chain. 38 | 39 | ## Compilation failed 40 | 41 | Most often, this is due to a not up to date compiler. 42 | 43 | You should update your Rust installation. 44 | This is usually done with `rustup update`. 45 | 46 | ## Copy-Paste problem on Windows 47 | 48 | * If you are using `cmd.exe` or the native PowerShell command line, remember to use ctrlv. Don't use the the system menu Edit/Paste from the window's top-left icon. 49 | 50 | * If you are a Windows Terminal user, you need to change its built-in pasting shortcut. Newer versions of Windows Terminal uses ctrlv for pasting. You would find a line similar to `{ "command": "paste", "keys": "ctrl+v" },` in its configuration file. This will interfere with SafeCloset's handing of ctrlv. You have to change Windows Terminal's pasting shortcut to something else (not ctrlv) to make SafeCloset's multiline pasting work. For example, you can use the setting `{ "command": "paste", "keys": "ctrl+shift+v" },` to use ctrlshiftv for pasting in Windows Terminal. After that, ctrlv should work for multiline text pasting in SafeCloset. 51 | 52 | ## Copy-Paste problem in Termux 53 | 54 | For copy-pasting to work properly, you also need to install the [Termux:API](https://wiki.termux.com/wiki/Termux:API), which is an Android APP, just like Termux. 55 | 56 | You have to install Termux and Termux:API from the same place. For example, if you installed Termux from F-Droid, install Termux:API from F-Droid, too. Mixing the installation from different stores will cause compatibility issues, because each download website uses a specific key for keysigning Termux and Addons. 57 | 58 | -------------------------------------------------------------------------------- /website/docs/js/autoclose-tree.js: -------------------------------------------------------------------------------- 1 | // This optionnal mkdocs add-on eases navigation when the sidenav 2 | // is big by hiding items of the not selected main items. 3 | ;(function(main){ 4 | let sidenav = document.querySelector("ul.nav.bs-sidenav"); 5 | let hasScrollbar = sidenav.scrollHeight > sidenav.clientHeight; 6 | if (!hasScrollbar) return; // the standard behavior is fine 7 | let items = document.querySelectorAll(".nav-item"); 8 | function open(mainItem) { 9 | mainItem.classList.add("at-selected"); 10 | for ( 11 | let item = mainItem.nextElementSibling; 12 | item && !item.classList.contains("main"); 13 | item = item.nextElementSibling 14 | ) { 15 | item.style.display = "block"; 16 | } 17 | } 18 | function closeAll(){ 19 | for (let item of items) { 20 | if (item.classList.contains("main")) { 21 | item.classList.remove("at-selected"); 22 | } else { 23 | item.style.display = "none"; 24 | } 25 | } 26 | } 27 | function onMainClicked(){ 28 | let opening = !this.classList.contains("at-selected"); 29 | closeAll(); 30 | if (opening) open(this); 31 | } 32 | function extractUrlHash(url){ 33 | let match = url.match(/#[^?&]+/); 34 | return match ? match[0] : null; 35 | } 36 | function extractItemHash(item){ 37 | let a = item.querySelector("a"); 38 | return a ? extractUrlHash(a.href) : null; 39 | } 40 | function showHash(hash) { 41 | let lastMain; 42 | for (let item of items) { 43 | if (item.classList.contains("main")) { 44 | lastMain = item; 45 | } 46 | let itemHash = extractItemHash(item); 47 | if (itemHash && itemHash == hash) { 48 | open(lastMain); 49 | return; 50 | } 51 | } 52 | } 53 | 54 | // adding a listener on all nav-item elements to make them 55 | // open or close the sub-items 56 | ;[].forEach.call(items, item => { 57 | if (item.classList.contains("main")) { 58 | item.addEventListener("click", onMainClicked); 59 | lastMain = item; 60 | } else { 61 | item.style.display = "none"; 62 | } 63 | }); 64 | // if we came to the page with a hash, we open the relevant part of 65 | // the nav 66 | if (document.location.hash) { 67 | showHash(document.location.hash); 68 | } 69 | // hook on internal links 70 | for (let a of document.querySelectorAll(".col-md-9 a")) { 71 | a.addEventListener("click", function(){ 72 | closeAll(); 73 | showHash(extractUrlHash(a.href)); 74 | }); 75 | } 76 | let lastActive = sidenav.querySelector(".active"); 77 | document.addEventListener("scroll", function(){ 78 | let active = sidenav.querySelector(".active"); 79 | if (active != lastActive) { 80 | lastActive = active; 81 | let activeHash = extractItemHash(active); 82 | closeAll(); 83 | showHash(activeHash); 84 | } 85 | }); 86 | })(); 87 | -------------------------------------------------------------------------------- /website/docs/js/link-to-dystroy.js: -------------------------------------------------------------------------------- 1 | 2 | (function ltd_main(){ 3 | function updateLink() { 4 | $(".link-to-dystroy").remove(); 5 | let $brand = $(".navbar-brand"); 6 | let available = $brand.offset().left; 7 | if (available < 30) return; 8 | $("") 9 | .attr("href", "https://dystroy.org") 10 | .addClass("link-to-dystroy") 11 | .prependTo(".navbar"); 12 | } 13 | window.addEventListener("resize", updateLink); 14 | updateLink(); 15 | })(); 16 | 17 | 18 | -------------------------------------------------------------------------------- /website/docs/usage.md: -------------------------------------------------------------------------------- 1 | 2 | Be sure to read first the [usage overview](../#usage-overview) section: it may already contain the answer you need. 3 | 4 | # Keys 5 | 6 | Key | Action 7 | :-:|- 8 | ? | Open the help screen 9 | ctrln | Create a drawer (in the open drawer, or at root when none is open) 10 | ctrlo | Open a drawer 11 | ctrlu | Close the current drawer, without saving (you're back in the upper level one if you close a deep drawer) 12 | ctrls | Save the current drawer and all upper drawers 13 | ctrlq | Quit without saving (with no confirmation) 14 | ctrlh | Toggle hidding password chars or unselected values 15 | ctrlf | Toggle folding: open either all values or just the selected one 16 | / | Start searching the current drawer. Do enter or use the down or up arrow key to freeze it. Do esc to cancel the search 17 | / then esc | Remove the current filtering 18 | esc | Cancel current field edition 19 | tab | Create a new entry or edit the value if you're already editing an entry's name 20 | arrow keys | Move selection, selecting either an entry name or a value (or move the cursor when in an input field) 21 | i or insert | Start editing the selected name or value 22 | a | Start editing the selected name or value, cursor at the end 23 | n | Add a new entry at the end 24 | N | Add a new entry immediately after the selected one 25 | d | Remove the selected entry (with confirmation) 26 | Enter | Validate the current edition 27 | altEnter or ctrlEnter| New line in the currently edited value 28 | ctrlc | Copy 29 | ctrlx | Cut 30 | ctrlv | Paste 31 | 32 | Note that single key shortcuts can't be used when in an input field. To leave an input field, hit esc. 33 | 34 | # Launch 35 | 36 | SafeCloset is always launched with the path to the closet file to create or open as argument: 37 | 38 | 39 | ```bash 40 | safecloset my/secrets.closet 41 | ``` 42 | 43 | ## Alias and --open 44 | 45 | You may find convenient to have a shortcut to open your most common closet. 46 | 47 | For example, you may have this in your shell configuration: 48 | 49 | ```bash 50 | function xx { 51 | safecloset -o ~/my/secrets.closet 52 | } 53 | ``` 54 | 55 | Creating drawers isn't something you frequently do, hence the `--open` option (`-o` in short) which skips the first screen. 56 | 57 | # Most typical sessions 58 | 59 | SafeCloset is designed for fast sessions. Here are three examples of standard usage. 60 | 61 | ## Find and read an entry 62 | 63 | - open the usual closet (probably with `-o`) 64 | - type the passphrase and hit enter to open the drawer 65 | - hit / then a few letters to find your entry and see its value 66 | 67 | If the value is multiline, you may have to select it to read it entirely: 68 | 69 | - hit enter to freeze the search 70 | - navigate with the arrow keys to the desired value, it automatically opens 71 | 72 | You don't have to bother closing SafeCloset as it will automatically close after 120 seconds. 73 | 74 | ## Create an entry 75 | 76 | - open the usual closet (with `-o`) 77 | - type the passphrase and hit enter to open the drawer 78 | - Hit n and fill the input with the entry name 79 | - hit tab to go to the value input, and fill it 80 | - validate the value with enter 81 | - maybe move the entry with ctrlup 82 | - hit ctrls then ctrlq to save and quit 83 | 84 | ## Edit an entry's value 85 | 86 | - open the usual closet (probably with `-o`) 87 | - type the passphrase and hit enter to open the drawer 88 | - hit / then a few letters to find your entry 89 | - hit the right arrow key to select the value 90 | - hit a to edit the field (hit i if you prefer to have the cursor on start) 91 | - edit the field, then hit enter to validate the change 92 | - hit ctrls then ctrlq to save and quit 93 | 94 | # Create a drawer 95 | 96 | ## Shallow drawer 97 | 98 | A "shallow" drawer is one which can be open from the top of the closet, with its own password. 99 | 100 | To create a deep drawer, from the initial screen you get at closet creation or opening, hit ctrln, type the passphrase, then hit enter. 101 | 102 | ![new closet](img/new-closet.png) 103 | 104 | ![drawer creation](img/drawer-creation.png) 105 | 106 | ## Deep drawer 107 | 108 | If you're a secret agent expecting to be tortured any day, you may be bothered by the fact an attacker having all the versions of the closet file can determine how many shallow drawers you're really editing. 109 | 110 | The solution is to use a deep driver. 111 | 112 | A deep drawer is hidden inside another drawer, and it's not possible to know whether you edited a parent or a deeper drawer. 113 | 114 | The downside of deep drawers is that you need to open the parent before you open the deeper drawer, which means typing two (or more) passphrases. 115 | 116 | In most cases, you don't need such level of secrecy. Most users should be happy with one or a few shallow drawers. 117 | 118 | To create a deep drawer, first open the parent drawer, then hit ctrln, type the passphrase, then hit enter. 119 | 120 | # Open a drawer 121 | 122 | Hit ctrlo, type the passphrase, then hit enter. 123 | 124 | To open a deep drawer, you must first open its parent. 125 | 126 | # Close 127 | 128 | To save, do ctrls. 129 | 130 | To quit, hit ctrlq. 131 | 132 | To close the current drawer (which lets you be back in the upper one if you're in a deep drawer), hist ctrlu. 133 | 134 | # Search 135 | 136 | SafeCloset's search ignores case and diacritics, and normalizes Unicode characters. 137 | 138 | It's fuzzy and takes into account the character position to not only find the matching entries but select the most relevant one: 139 | 140 | ![searching CA](img/search-ca.png) 141 | 142 | Searching is typically done by 143 | 144 | - typing / then a few letters of your search 145 | - hitting enter if the selection is right, or an arrow key or two to navigate to it 146 | 147 | To remove the search, hit / then esc. 148 | 149 | # Edit the drawer 150 | 151 | Use the arrow keys to navigate among entries, and from name to value or value to name. 152 | 153 | Hit i or a to edit the selected cell, with the cursor either at start or end of the field. 154 | 155 | You may go to the next cell, and edit it, with the tab key. 156 | 157 | Hit n to add an entry. 158 | 159 | Use ctrl and ctrl to move the selected entry. 160 | 161 | If you have some text in the clipboard, you may paste it in the current cell with ctrlv. 162 | 163 | # Import 164 | 165 | You may import keys/values from another drawer, from a drawer in another file, or from a CSV file. 166 | 167 | This may be useful to reorganize your drawers, or when you edited your closet in two copies on two computers. 168 | 169 | To start the import, open the destination drawer, select `Import` in the menu, then answer the questions of the wizard. 170 | 171 | This is a safe operation: 172 | 173 | * no value is even modified: if a key is present both in the source and the destination, and the values aren't the same, the new value after import is the concatenation of both values with a separator, and *you* decide what parts to keep 174 | * there's no change until you save, so if you made a mess by importing the wrong drawer, just don't save 175 | 176 | # Advice 177 | 178 | 1. Use the search to navigate among entries. That's the most efficient solution. It's OK to have thousands of secrets in your drawer. 179 | 1. You may not need deep drawers. They make you open twice, with two passwords, so don't use them without reason. 180 | 1. Don't use drawers as categories. They separate audience or security levels and ensure plausible deniability. You're supposed to have one drawer for most of your secrets. Maybe a second one if you have a *very secret* level. Or one with your work secrets that you may open with colleagues nearby. Or one for the family that even the kids can read. This shouldn't be more than 3 or 4 drawers at most. 181 | 1. Backup your closet files. They're not readable as long as your passphrases can't be guessed so you don't have to hide those files and it's most important to not lose them. 182 | 1. Use hard to guess passphrases, but ones that you can remember for a very long time. 183 | 1. You may keep the executables of all platforms on your USB keys, so that you can read your secrets everywhere. 184 | 1. Don't forget to have your closet file in your backup plan (did I say it already ? In any case it's *important*) 185 | 186 | -------------------------------------------------------------------------------- /website/mkdocs.yml: -------------------------------------------------------------------------------- 1 | site_name: SafeCloset 2 | site_description: 'SafeCloset, a secure cross-platform secret holder' 3 | site_url: https://dystroy.org/safecloset 4 | repo_url: https://github.com/Canop/safecloset 5 | edit_uri: '' 6 | 7 | nav: 8 | - Overview: index.md 9 | - Features: features.md 10 | - Install: install.md 11 | - Usage: usage.md 12 | - Community: community.md 13 | 14 | extra_css: 15 | - css/extra.css 16 | - css/link-to-dystroy.css 17 | 18 | extra_javascript: 19 | - js/link-to-dystroy.js 20 | 21 | theme: 22 | name: mkdocs 23 | favicon: /favicon.png 24 | nav_style: dark 25 | custom_dir: custom_theme 26 | highlightjs: true 27 | hljs_style: Dracula 28 | hljs_languages: 29 | - yaml 30 | - ini 31 | - css 32 | - rust 33 | 34 | markdown_extensions: 35 | - admonition 36 | - def_list 37 | --------------------------------------------------------------------------------