mirror of
https://github.com/elwrcl/pasx.git
synced 2026-10-31 10:58:04 +03:00
init
This commit is contained in:
22 files changed
+11922
No files matched your search
@@ -0,0 +1,4 @@
|
|||||||
|
/target
|
||||||
|
/result
|
||||||
|
/docs
|
||||||
|
/.superpowers
|
||||||
Generated
+1842
File diff suppressed because it is too large.
Load diff
+15
@@ -0,0 +1,15 @@
|
|||||||
|
[package]
|
||||||
|
name = "pasx"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2024"
|
||||||
|
description = "Terminal password generator"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4.6", features = ["derive"] }
|
||||||
|
clap_complete = "4.6"
|
||||||
|
clap_complete_nushell = "4.6"
|
||||||
|
rand = "0.10"
|
||||||
|
ratatui = "0.30"
|
||||||
|
thiserror = "2"
|
||||||
+202
@@ -0,0 +1,202 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
||||||
|
|
||||||
|
APPENDIX: How to apply the Apache License to your work.
|
||||||
|
|
||||||
|
To apply the Apache License to your work, attach the following
|
||||||
|
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||||
|
replaced with your own identifying information. (Don't include
|
||||||
|
the brackets!) The text should be enclosed in the appropriate
|
||||||
|
comment syntax for the file format. We also recommend that a
|
||||||
|
file or class name and description of purpose be included on the
|
||||||
|
same "printed page" as the copyright notice for easier
|
||||||
|
identification within third-party archives.
|
||||||
|
|
||||||
|
Copyright [yyyy] [name of copyright owner]
|
||||||
|
|
||||||
|
Licensed under the Apache License, Version 2.0 (the "License");
|
||||||
|
you may not use this file except in compliance with the License.
|
||||||
|
You may obtain a copy of the License at
|
||||||
|
|
||||||
|
http://www.apache.org/licenses/LICENSE-2.0
|
||||||
|
|
||||||
|
Unless required by applicable law or agreed to in writing, software
|
||||||
|
distributed under the License is distributed on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||||
|
See the License for the specific language governing permissions and
|
||||||
|
limitations under the License.
|
||||||
+21
@@ -0,0 +1,21 @@
|
|||||||
|
MIT License
|
||||||
|
|
||||||
|
Copyright (c) 2026 elwrcl
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||||
|
of this software and associated documentation files (the "Software"), to deal
|
||||||
|
in the Software without restriction, including without limitation the rights
|
||||||
|
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||||
|
copies of the Software, and to permit persons to whom the Software is
|
||||||
|
furnished to do so, subject to the following conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice shall be included in all
|
||||||
|
copies or substantial portions of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||||
|
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||||
|
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||||
|
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||||
|
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||||
|
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||||
|
SOFTWARE.
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
# EFF Large Wordlist
|
||||||
|
|
||||||
|
`src/generator/wordlist.txt` is the EFF Large Wordlist for Passphrases, with the
|
||||||
|
dice numbers stripped.
|
||||||
|
|
||||||
|
Source: https://www.eff.org/files/2016/07/18/eff_large_wordlist.txt
|
||||||
|
Author: Electronic Frontier Foundation
|
||||||
|
License: CC BY 3.0 US (https://creativecommons.org/licenses/by/3.0/us/)
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# pasx
|
||||||
|
|
||||||
|
A full-screen terminal password generator. Random characters or passphrases,
|
||||||
|
live entropy, clipboard copy. It generates passwords; it never stores them.
|
||||||
|
|
||||||
|
Copying delegates to the platform's own clipboard tool — `wl-copy`, `xclip`,
|
||||||
|
`xsel` or `pbcopy`, whichever is on PATH. A short-lived process cannot hold a
|
||||||
|
Wayland or X11 selection open on its own; those tools daemonize, so the
|
||||||
|
password is still there after `pasx` exits. The Nix package puts `wl-copy` on
|
||||||
|
PATH for you.
|
||||||
|
|
||||||
|
## Run
|
||||||
|
|
||||||
|
```
|
||||||
|
nix develop --command cargo run
|
||||||
|
```
|
||||||
|
|
||||||
|
The TUI needs at least a 40x14 terminal; below that it shows a "terminal too
|
||||||
|
small" message instead of a broken layout.
|
||||||
|
|
||||||
|
## Install
|
||||||
|
|
||||||
|
```
|
||||||
|
nix build
|
||||||
|
./result/bin/pasx
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add it to a profile: `nix profile install .`
|
||||||
|
|
||||||
|
## Keys
|
||||||
|
|
||||||
|
| Key | Action |
|
||||||
|
|---|---|
|
||||||
|
| Up / Down | select a setting |
|
||||||
|
| Left / Right | change the selected setting |
|
||||||
|
| Space, Enter | toggle the selected setting |
|
||||||
|
| `r` | regenerate |
|
||||||
|
| `c` | copy to clipboard |
|
||||||
|
| Tab | switch between character and passphrase mode |
|
||||||
|
| `q`, Esc, Ctrl-C | quit |
|
||||||
|
|
||||||
|
## Command line
|
||||||
|
|
||||||
|
```
|
||||||
|
pasx --length 24 --no-symbols # open the TUI pre-configured
|
||||||
|
pasx gen -l 18 # print one password and exit
|
||||||
|
pasx gen -l 18 --copy # ...and copy it
|
||||||
|
pasx completions nu # print a shell completion script
|
||||||
|
```
|
||||||
|
|
||||||
|
Flags: `--mode <charset|passphrase>`, `--length`, `--no-upper`, `--no-lower`,
|
||||||
|
`--no-digits`, `--no-symbols`, `--exclude-ambiguous`, `--words`, `--separator`,
|
||||||
|
`--capitalize`, `--add-number`.
|
||||||
|
|
||||||
|
## Shell completions
|
||||||
|
|
||||||
|
`pasx completions <shell>` prints a completion script for `nu`, `bash`, `zsh`,
|
||||||
|
or `fish` to stdout; each is generated from the clap definition, so a new flag
|
||||||
|
stays covered with no separate edit.
|
||||||
|
|
||||||
|
The Nix package ships the Nushell script at `share/pasx/pasx.nu`. Source it
|
||||||
|
from your config:
|
||||||
|
|
||||||
|
```nu
|
||||||
|
source ~/.nix-profile/share/pasx/pasx.nu
|
||||||
|
```
|
||||||
|
|
||||||
|
Without Nix, save the script wherever your shell loads completions from, e.g.:
|
||||||
|
|
||||||
|
```
|
||||||
|
pasx completions bash > ~/.local/share/bash-completion/completions/pasx
|
||||||
|
pasx completions zsh > ~/.zfunc/_pasx
|
||||||
|
pasx completions fish > ~/.config/fish/completions/pasx.fish
|
||||||
|
pasx completions nu | save -f ~/.config/nushell/pasx.nu
|
||||||
|
```
|
||||||
|
|
||||||
|
## Development
|
||||||
|
|
||||||
|
```
|
||||||
|
nix develop
|
||||||
|
cargo test
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
```
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
Dual-licensed under either of `LICENSE-MIT` or `LICENSE-APACHE`, at your
|
||||||
|
option.
|
||||||
|
|
||||||
|
The EFF wordlist bundled in `src/generator/wordlist.txt` is CC BY 3.0 US; see
|
||||||
|
`LICENSES/eff-wordlist.md`.
|
||||||
Generated
+27
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"nodes": {
|
||||||
|
"nixpkgs": {
|
||||||
|
"locked": {
|
||||||
|
"lastModified": 1790185690,
|
||||||
|
"narHash": "sha256-xJ+X4hBtOcAFGBOe5nAMyMUeF9foJBmIOu3NjBqBycU=",
|
||||||
|
"owner": "NixOS",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"rev": "4975466d324710c576dc11ad614684e6bd8cad8e",
|
||||||
|
"type": "github"
|
||||||
|
},
|
||||||
|
"original": {
|
||||||
|
"owner": "NixOS",
|
||||||
|
"ref": "nixos-unstable",
|
||||||
|
"repo": "nixpkgs",
|
||||||
|
"type": "github"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": {
|
||||||
|
"inputs": {
|
||||||
|
"nixpkgs": "nixpkgs"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"root": "root",
|
||||||
|
"version": 7
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
{
|
||||||
|
description = "pasx - terminal password generator";
|
||||||
|
|
||||||
|
inputs.nixpkgs.url = "github:NixOS/nixpkgs/nixos-unstable";
|
||||||
|
|
||||||
|
outputs = { self, nixpkgs }:
|
||||||
|
let
|
||||||
|
systems = [ "x86_64-linux" "aarch64-linux" ];
|
||||||
|
forAllSystems = f:
|
||||||
|
nixpkgs.lib.genAttrs systems (system: f nixpkgs.legacyPackages.${system});
|
||||||
|
in
|
||||||
|
{
|
||||||
|
devShells = forAllSystems (pkgs: {
|
||||||
|
default = pkgs.mkShell {
|
||||||
|
packages = [
|
||||||
|
pkgs.cargo
|
||||||
|
pkgs.rustc
|
||||||
|
pkgs.clippy
|
||||||
|
pkgs.rustfmt
|
||||||
|
pkgs.rust-analyzer
|
||||||
|
];
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
packages = forAllSystems (pkgs: {
|
||||||
|
default = pkgs.rustPlatform.buildRustPackage {
|
||||||
|
pname = "pasx";
|
||||||
|
version = "0.1.0";
|
||||||
|
src = ./.;
|
||||||
|
cargoLock.lockFile = ./Cargo.lock;
|
||||||
|
|
||||||
|
nativeBuildInputs = [ pkgs.clippy pkgs.rustfmt pkgs.makeWrapper ];
|
||||||
|
postCheck = ''
|
||||||
|
cargo clippy --all-targets -- -D warnings
|
||||||
|
cargo fmt --check
|
||||||
|
'';
|
||||||
|
|
||||||
|
postInstall = ''
|
||||||
|
mkdir -p $out/share/pasx
|
||||||
|
$out/bin/pasx completions nu > $out/share/pasx/pasx.nu
|
||||||
|
|
||||||
|
wrapProgram $out/bin/pasx \
|
||||||
|
--prefix PATH : ${pkgs.lib.makeBinPath [ pkgs.wl-clipboard ]}
|
||||||
|
'';
|
||||||
|
|
||||||
|
meta = {
|
||||||
|
description = "Terminal password generator";
|
||||||
|
license = with pkgs.lib.licenses; [ mit asl20 ];
|
||||||
|
mainProgram = "pasx";
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
checks = forAllSystems (pkgs: {
|
||||||
|
default = self.packages.${pkgs.stdenv.hostPlatform.system}.default;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
}
|
||||||
+253
@@ -0,0 +1,253 @@
|
|||||||
|
use crate::generator::charset::CharsetOptions;
|
||||||
|
use crate::generator::passphrase::PassphraseOptions;
|
||||||
|
use clap::{Args, Parser, Subcommand, ValueEnum};
|
||||||
|
|
||||||
|
#[derive(Parser, Debug)]
|
||||||
|
#[command(name = "pasx", version, about = "Terminal password generator")]
|
||||||
|
pub struct Cli {
|
||||||
|
#[command(subcommand)]
|
||||||
|
pub command: Option<Command>,
|
||||||
|
|
||||||
|
#[command(flatten)]
|
||||||
|
pub opts: GenArgs,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand, Debug)]
|
||||||
|
pub enum Command {
|
||||||
|
/// Generate one password, print it, and exit
|
||||||
|
Gen(GenCmdArgs),
|
||||||
|
/// Print a shell completion script
|
||||||
|
Completions {
|
||||||
|
#[arg(value_enum)]
|
||||||
|
shell: ShellKind,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug)]
|
||||||
|
pub struct GenCmdArgs {
|
||||||
|
#[command(flatten)]
|
||||||
|
pub opts: GenArgs,
|
||||||
|
|
||||||
|
/// Also copy the password to the clipboard
|
||||||
|
#[arg(long)]
|
||||||
|
pub copy: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Args, Debug, Clone)]
|
||||||
|
pub struct GenArgs {
|
||||||
|
/// Generation mode
|
||||||
|
#[arg(short = 'm', long, value_enum, default_value_t = Mode::Charset)]
|
||||||
|
pub mode: Mode,
|
||||||
|
|
||||||
|
/// Password length in characters
|
||||||
|
#[arg(short = 'l', long, default_value_t = 18)]
|
||||||
|
pub length: usize,
|
||||||
|
|
||||||
|
/// Exclude uppercase letters
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_upper: bool,
|
||||||
|
|
||||||
|
/// Exclude lowercase letters
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_lower: bool,
|
||||||
|
|
||||||
|
/// Exclude digits
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_digits: bool,
|
||||||
|
|
||||||
|
/// Exclude symbols
|
||||||
|
#[arg(long)]
|
||||||
|
pub no_symbols: bool,
|
||||||
|
|
||||||
|
/// Exclude look-alike characters (0 O o l 1 I)
|
||||||
|
#[arg(long)]
|
||||||
|
pub exclude_ambiguous: bool,
|
||||||
|
|
||||||
|
/// Number of words in passphrase mode
|
||||||
|
#[arg(short = 'w', long, default_value_t = 5)]
|
||||||
|
pub words: usize,
|
||||||
|
|
||||||
|
/// Separator between words in passphrase mode
|
||||||
|
#[arg(long, default_value_t = '-')]
|
||||||
|
pub separator: char,
|
||||||
|
|
||||||
|
/// Capitalize each word in passphrase mode
|
||||||
|
#[arg(long)]
|
||||||
|
pub capitalize: bool,
|
||||||
|
|
||||||
|
/// Append a random digit in passphrase mode
|
||||||
|
#[arg(long)]
|
||||||
|
pub add_number: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Mode {
|
||||||
|
Charset,
|
||||||
|
Passphrase,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(ValueEnum, Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ShellKind {
|
||||||
|
Nu,
|
||||||
|
Bash,
|
||||||
|
Zsh,
|
||||||
|
Fish,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl GenArgs {
|
||||||
|
pub fn charset_options(&self) -> CharsetOptions {
|
||||||
|
CharsetOptions {
|
||||||
|
length: self.length,
|
||||||
|
upper: !self.no_upper,
|
||||||
|
lower: !self.no_lower,
|
||||||
|
digits: !self.no_digits,
|
||||||
|
symbols: !self.no_symbols,
|
||||||
|
exclude_ambiguous: self.exclude_ambiguous,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn passphrase_options(&self) -> PassphraseOptions {
|
||||||
|
PassphraseOptions {
|
||||||
|
words: self.words,
|
||||||
|
separator: self.separator,
|
||||||
|
capitalize: self.capitalize,
|
||||||
|
add_number: self.add_number,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
use clap::CommandFactory;
|
||||||
|
|
||||||
|
pub fn write_completions(shell: ShellKind, out: &mut impl std::io::Write) -> std::io::Result<()> {
|
||||||
|
let mut cmd = Cli::command();
|
||||||
|
let name = cmd.get_name().to_string();
|
||||||
|
|
||||||
|
// clap_complete panics internally if the writer it is handed fails, so it
|
||||||
|
// renders into memory and the fallible write happens here.
|
||||||
|
let mut script = Vec::new();
|
||||||
|
match shell {
|
||||||
|
ShellKind::Nu => {
|
||||||
|
clap_complete::generate(clap_complete_nushell::Nushell, &mut cmd, name, &mut script)
|
||||||
|
}
|
||||||
|
ShellKind::Bash => {
|
||||||
|
clap_complete::generate(clap_complete::Shell::Bash, &mut cmd, name, &mut script)
|
||||||
|
}
|
||||||
|
ShellKind::Zsh => {
|
||||||
|
clap_complete::generate(clap_complete::Shell::Zsh, &mut cmd, name, &mut script)
|
||||||
|
}
|
||||||
|
ShellKind::Fish => {
|
||||||
|
clap_complete::generate(clap_complete::Shell::Fish, &mut cmd, name, &mut script)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.write_all(&script)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use clap::{CommandFactory, Parser};
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn argument_definition_is_valid() {
|
||||||
|
Cli::command().debug_assert();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn bare_invocation_has_no_subcommand_and_full_defaults() {
|
||||||
|
let cli = Cli::parse_from(["pasx"]);
|
||||||
|
assert!(cli.command.is_none());
|
||||||
|
assert_eq!(cli.opts.mode, Mode::Charset);
|
||||||
|
assert_eq!(cli.opts.charset_options(), CharsetOptions::default());
|
||||||
|
assert_eq!(cli.opts.passphrase_options(), PassphraseOptions::default());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn negative_flags_switch_classes_off() {
|
||||||
|
let cli = Cli::parse_from(["pasx", "--length", "24", "--no-symbols", "--no-upper"]);
|
||||||
|
let opts = cli.opts.charset_options();
|
||||||
|
assert_eq!(opts.length, 24);
|
||||||
|
assert!(!opts.symbols);
|
||||||
|
assert!(!opts.upper);
|
||||||
|
assert!(opts.lower);
|
||||||
|
assert!(opts.digits);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrase_flags_reach_the_options() {
|
||||||
|
let cli = Cli::parse_from([
|
||||||
|
"pasx",
|
||||||
|
"--mode",
|
||||||
|
"passphrase",
|
||||||
|
"--words",
|
||||||
|
"7",
|
||||||
|
"--separator",
|
||||||
|
".",
|
||||||
|
"--capitalize",
|
||||||
|
]);
|
||||||
|
assert_eq!(cli.opts.mode, Mode::Passphrase);
|
||||||
|
let opts = cli.opts.passphrase_options();
|
||||||
|
assert_eq!(opts.words, 7);
|
||||||
|
assert_eq!(opts.separator, '.');
|
||||||
|
assert!(opts.capitalize);
|
||||||
|
assert!(!opts.add_number);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn gen_subcommand_takes_the_same_flags_plus_copy() {
|
||||||
|
let cli = Cli::parse_from(["pasx", "gen", "-l", "32", "--copy"]);
|
||||||
|
match cli.command {
|
||||||
|
Some(Command::Gen(args)) => {
|
||||||
|
assert_eq!(args.opts.charset_options().length, 32);
|
||||||
|
assert!(args.copy);
|
||||||
|
}
|
||||||
|
other => panic!("expected gen subcommand, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn completions_subcommand_parses_every_shell() {
|
||||||
|
for (arg, expected) in [
|
||||||
|
("nu", ShellKind::Nu),
|
||||||
|
("bash", ShellKind::Bash),
|
||||||
|
("zsh", ShellKind::Zsh),
|
||||||
|
("fish", ShellKind::Fish),
|
||||||
|
] {
|
||||||
|
let cli = Cli::parse_from(["pasx", "completions", arg]);
|
||||||
|
match cli.command {
|
||||||
|
Some(Command::Completions { shell }) => assert_eq!(shell, expected),
|
||||||
|
other => panic!("expected completions subcommand, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn exclude_ambiguous_reaches_the_options() {
|
||||||
|
let cli = Cli::parse_from(["pasx", "--exclude-ambiguous"]);
|
||||||
|
assert!(cli.opts.charset_options().exclude_ambiguous);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nushell_completions_mention_the_binary_and_its_flags() {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_completions(ShellKind::Nu, &mut buf).unwrap();
|
||||||
|
let script = String::from_utf8(buf).unwrap();
|
||||||
|
assert!(script.contains("pasx"), "{script}");
|
||||||
|
assert!(script.contains("--exclude-ambiguous"), "{script}");
|
||||||
|
assert!(script.contains("pasx gen"), "{script}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_shell_produces_a_non_empty_script() {
|
||||||
|
let shells = [
|
||||||
|
ShellKind::Nu,
|
||||||
|
ShellKind::Bash,
|
||||||
|
ShellKind::Zsh,
|
||||||
|
ShellKind::Fish,
|
||||||
|
];
|
||||||
|
for shell in shells {
|
||||||
|
let mut buf = Vec::new();
|
||||||
|
write_completions(shell, &mut buf).unwrap();
|
||||||
|
assert!(!buf.is_empty(), "{shell:?} produced nothing");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,114 @@
|
|||||||
|
// A short-lived process cannot own a Wayland or X11 selection: the selection
|
||||||
|
// belongs to the process and dies with it. The platform clipboard tools
|
||||||
|
// daemonize themselves and are the only mechanism that actually persists, so
|
||||||
|
// this shells out to whichever one is present rather than using a clipboard
|
||||||
|
// crate.
|
||||||
|
use anyhow::{Result, anyhow, bail};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
type Tool = (&'static str, &'static [&'static str]);
|
||||||
|
|
||||||
|
const TOOLS: &[Tool] = &[
|
||||||
|
("wl-copy", &[]),
|
||||||
|
("xclip", &["-selection", "clipboard"]),
|
||||||
|
("xsel", &["--clipboard", "--input"]),
|
||||||
|
("pbcopy", &[]),
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn copy(text: &str) -> Result<()> {
|
||||||
|
copy_with(TOOLS, text)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn copy_with(tools: &[Tool], text: &str) -> Result<()> {
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for (program, args) in tools {
|
||||||
|
match feed(program, args, text) {
|
||||||
|
Ok(()) => return Ok(()),
|
||||||
|
Err(err) if err.kind() == std::io::ErrorKind::NotFound => missing.push(*program),
|
||||||
|
Err(err) => return Err(anyhow!(err).context(format!("{program} failed"))),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
bail!("no clipboard tool found (tried {})", missing.join(", "))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn feed(program: &str, args: &[&str], text: &str) -> std::io::Result<()> {
|
||||||
|
let mut child = Command::new(program)
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::piped())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::piped())
|
||||||
|
.spawn()?;
|
||||||
|
|
||||||
|
let written = match child.stdin.take() {
|
||||||
|
Some(mut stdin) => stdin.write_all(text.as_bytes()),
|
||||||
|
None => Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
// wait_with_output reaps the child and collects stderr even when the
|
||||||
|
// write failed: dropping a Child does not wait, and the TUI calls this
|
||||||
|
// repeatedly over a long session.
|
||||||
|
let output = child.wait_with_output()?;
|
||||||
|
written?;
|
||||||
|
|
||||||
|
if output.status.success() {
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
let stderr = String::from_utf8_lossy(&output.stderr);
|
||||||
|
let stderr = stderr.trim();
|
||||||
|
if stderr.is_empty() {
|
||||||
|
Err(std::io::Error::other(format!(
|
||||||
|
"{program} exited with {}",
|
||||||
|
output.status
|
||||||
|
)))
|
||||||
|
} else {
|
||||||
|
Err(std::io::Error::other(format!(
|
||||||
|
"{program} exited with {}: {stderr}",
|
||||||
|
output.status
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_when_no_clipboard_tool_is_available() {
|
||||||
|
let err = copy_with(&[("pasx-no-such-tool", &[])], "secret").unwrap_err();
|
||||||
|
assert!(err.to_string().contains("no clipboard tool"), "{err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn feeds_the_text_to_the_first_tool_that_exists() {
|
||||||
|
// `cat` stands in for a clipboard tool: it exists everywhere, reads
|
||||||
|
// stdin, and exits cleanly.
|
||||||
|
copy_with(&[("pasx-no-such-tool", &[]), ("cat", &[])], "secret").unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn reports_a_tool_that_exists_but_fails() {
|
||||||
|
copy_with(&[("false", &[])], "secret").unwrap_err();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failing_tool_s_stderr_reaches_the_error() {
|
||||||
|
let err = copy_with(
|
||||||
|
&[(
|
||||||
|
"sh",
|
||||||
|
&[
|
||||||
|
"-c",
|
||||||
|
"echo permission denied talking to daemon 1>&2; exit 1",
|
||||||
|
],
|
||||||
|
)],
|
||||||
|
"secret",
|
||||||
|
)
|
||||||
|
.unwrap_err();
|
||||||
|
let full = format!("{err:#}");
|
||||||
|
assert!(
|
||||||
|
full.contains("permission denied"),
|
||||||
|
"stderr was not surfaced: {full}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,219 @@
|
|||||||
|
use crate::generator::{GenError, MAX_LENGTH, MIN_LENGTH, RETRY_LIMIT};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
pub const UPPER: &str = "ABCDEFGHIJKLMNOPQRSTUVWXYZ";
|
||||||
|
pub const LOWER: &str = "abcdefghijklmnopqrstuvwxyz";
|
||||||
|
pub const DIGITS: &str = "0123456789";
|
||||||
|
pub const SYMBOLS: &str = "!@#$%^&*()-_=+[]{};:,.<>?/";
|
||||||
|
pub const AMBIGUOUS: &str = "0Ool1I";
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct CharsetOptions {
|
||||||
|
pub length: usize,
|
||||||
|
pub upper: bool,
|
||||||
|
pub lower: bool,
|
||||||
|
pub digits: bool,
|
||||||
|
pub symbols: bool,
|
||||||
|
pub exclude_ambiguous: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for CharsetOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
length: 18,
|
||||||
|
upper: true,
|
||||||
|
lower: true,
|
||||||
|
digits: true,
|
||||||
|
symbols: true,
|
||||||
|
exclude_ambiguous: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CharsetOptions {
|
||||||
|
pub fn enabled_count(&self) -> usize {
|
||||||
|
[self.upper, self.lower, self.digits, self.symbols]
|
||||||
|
.iter()
|
||||||
|
.filter(|on| **on)
|
||||||
|
.count()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn classes(&self) -> Vec<Vec<char>> {
|
||||||
|
[
|
||||||
|
(self.upper, UPPER),
|
||||||
|
(self.lower, LOWER),
|
||||||
|
(self.digits, DIGITS),
|
||||||
|
(self.symbols, SYMBOLS),
|
||||||
|
]
|
||||||
|
.into_iter()
|
||||||
|
.filter(|(on, _)| *on)
|
||||||
|
.map(|(_, set)| {
|
||||||
|
set.chars()
|
||||||
|
.filter(|c| !(self.exclude_ambiguous && AMBIGUOUS.contains(*c)))
|
||||||
|
.collect()
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn pool(&self) -> Vec<char> {
|
||||||
|
self.classes().into_iter().flatten().collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate(&self) -> Result<(), GenError> {
|
||||||
|
if !(MIN_LENGTH..=MAX_LENGTH).contains(&self.length) {
|
||||||
|
return Err(GenError::InvalidLength {
|
||||||
|
got: self.length,
|
||||||
|
min: MIN_LENGTH,
|
||||||
|
max: MAX_LENGTH,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if self.enabled_count() == 0 {
|
||||||
|
return Err(GenError::NoCharacterClass);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate<R: RngExt>(opts: &CharsetOptions, rng: &mut R) -> Result<String, GenError> {
|
||||||
|
opts.validate()?;
|
||||||
|
let classes = opts.classes();
|
||||||
|
let pool: Vec<char> = classes.iter().flatten().copied().collect();
|
||||||
|
|
||||||
|
for _ in 0..RETRY_LIMIT {
|
||||||
|
let candidate: Vec<char> = (0..opts.length)
|
||||||
|
.map(|_| pool[rng.random_range(0..pool.len())])
|
||||||
|
.collect();
|
||||||
|
if classes
|
||||||
|
.iter()
|
||||||
|
.all(|class| candidate.iter().any(|c| class.contains(c)))
|
||||||
|
{
|
||||||
|
return Ok(candidate.into_iter().collect());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(GenError::RetryLimitExceeded(RETRY_LIMIT))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
|
fn rng() -> StdRng {
|
||||||
|
StdRng::seed_from_u64(42)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn respects_requested_length() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 24,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert_eq!(out.chars().count(), 24);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn uses_only_enabled_classes() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 32,
|
||||||
|
upper: false,
|
||||||
|
symbols: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.chars()
|
||||||
|
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn includes_every_enabled_class() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 8,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
for seed in 0..50 {
|
||||||
|
let out = generate(&opts, &mut StdRng::seed_from_u64(seed)).unwrap();
|
||||||
|
assert!(out.chars().any(|c| c.is_ascii_uppercase()), "{out}");
|
||||||
|
assert!(out.chars().any(|c| c.is_ascii_lowercase()), "{out}");
|
||||||
|
assert!(out.chars().any(|c| c.is_ascii_digit()), "{out}");
|
||||||
|
assert!(out.chars().any(|c| SYMBOLS.contains(c)), "{out}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn excludes_ambiguous_characters_when_asked() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 64,
|
||||||
|
exclude_ambiguous: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(out.chars().all(|c| !AMBIGUOUS.contains(c)), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_length_below_minimum() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 3,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
generate(&opts, &mut rng()),
|
||||||
|
Err(GenError::InvalidLength {
|
||||||
|
got: 3,
|
||||||
|
min: MIN_LENGTH,
|
||||||
|
max: MAX_LENGTH
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_length_above_maximum() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 129,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
generate(&opts, &mut rng()),
|
||||||
|
Err(GenError::InvalidLength {
|
||||||
|
got: 129,
|
||||||
|
min: MIN_LENGTH,
|
||||||
|
max: MAX_LENGTH
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_empty_class_selection() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
upper: false,
|
||||||
|
lower: false,
|
||||||
|
digits: false,
|
||||||
|
symbols: false,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(generate(&opts, &mut rng()), Err(GenError::NoCharacterClass));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_pool_character_is_reachable() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 16,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let mut seen = std::collections::HashSet::new();
|
||||||
|
let mut r = rng();
|
||||||
|
for _ in 0..10_000 {
|
||||||
|
seen.extend(generate(&opts, &mut r).unwrap().chars());
|
||||||
|
}
|
||||||
|
for c in opts.pool() {
|
||||||
|
assert!(
|
||||||
|
seen.contains(&c),
|
||||||
|
"pool character {c:?} was never generated"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
use crate::generator::charset::CharsetOptions;
|
||||||
|
use crate::generator::passphrase::{PassphraseOptions, WORDS};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Strength {
|
||||||
|
Weak,
|
||||||
|
Fair,
|
||||||
|
Strong,
|
||||||
|
Excellent,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Strength {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Strength::Weak => "weak",
|
||||||
|
Strength::Fair => "fair",
|
||||||
|
Strength::Strong => "strong",
|
||||||
|
Strength::Excellent => "excellent",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// This is an upper bound, not an exact figure: generation is uniform over the
|
||||||
|
// set of passwords with every enabled class represented (see charset.rs's
|
||||||
|
// rejection sampling), which is smaller than the full pool^length space this
|
||||||
|
// formula assumes. The gap is small and shrinks with length — material only
|
||||||
|
// at the short end of the supported range, where the result is already
|
||||||
|
// labelled weak — so it is reported as-is rather than computed exactly.
|
||||||
|
pub fn charset_bits(opts: &CharsetOptions) -> f64 {
|
||||||
|
let pool = opts.pool().len();
|
||||||
|
if pool == 0 {
|
||||||
|
return 0.0;
|
||||||
|
}
|
||||||
|
opts.length as f64 * (pool as f64).log2()
|
||||||
|
}
|
||||||
|
|
||||||
|
// `capitalize` is deliberately absent from this figure: it is a deterministic
|
||||||
|
// transform (always titlecase, never a per-word coin flip), so it adds no
|
||||||
|
// uncertainty and must not be counted as if it did. Only `add_number`, which
|
||||||
|
// draws a random digit, adds bits.
|
||||||
|
pub fn passphrase_bits(opts: &PassphraseOptions) -> f64 {
|
||||||
|
let per_word = (WORDS.len() as f64).log2();
|
||||||
|
let digit = if opts.add_number { 10f64.log2() } else { 0.0 };
|
||||||
|
opts.words as f64 * per_word + digit
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strength(bits: f64) -> Strength {
|
||||||
|
match bits {
|
||||||
|
b if b < 50.0 => Strength::Weak,
|
||||||
|
b if b < 80.0 => Strength::Fair,
|
||||||
|
b if b < 110.0 => Strength::Strong,
|
||||||
|
_ => Strength::Excellent,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn full_pool_is_eighty_eight_characters() {
|
||||||
|
assert_eq!(CharsetOptions::default().pool().len(), 88);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charset_bits_match_the_pool_formula() {
|
||||||
|
let opts = CharsetOptions {
|
||||||
|
length: 18,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bits = charset_bits(&opts);
|
||||||
|
assert!((bits - 18.0 * 88f64.log2()).abs() < 1e-9);
|
||||||
|
assert!((bits - 116.3).abs() < 0.1, "{bits}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrase_bits_are_about_twelve_nine_per_word() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
words: 5,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let bits = passphrase_bits(&opts);
|
||||||
|
assert!((bits - 5.0 * 7776f64.log2()).abs() < 1e-9);
|
||||||
|
assert!((bits - 64.6).abs() < 0.1, "{bits}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capitalizing_adds_no_entropy() {
|
||||||
|
let plain = PassphraseOptions::default();
|
||||||
|
let capitalized = PassphraseOptions {
|
||||||
|
capitalize: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(passphrase_bits(&plain), passphrase_bits(&capitalized));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn adding_a_digit_adds_log2_of_ten() {
|
||||||
|
let plain = PassphraseOptions::default();
|
||||||
|
let numbered = PassphraseOptions {
|
||||||
|
add_number: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let delta = passphrase_bits(&numbered) - passphrase_bits(&plain);
|
||||||
|
assert!((delta - 10f64.log2()).abs() < 1e-9, "{delta}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn strength_bands_split_at_fifty_eighty_and_one_ten() {
|
||||||
|
assert_eq!(strength(49.9).label(), "weak");
|
||||||
|
assert_eq!(strength(50.0).label(), "fair");
|
||||||
|
assert_eq!(strength(79.9).label(), "fair");
|
||||||
|
assert_eq!(strength(80.0).label(), "strong");
|
||||||
|
assert_eq!(strength(109.9).label(), "strong");
|
||||||
|
assert_eq!(strength(110.0).label(), "excellent");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
use thiserror::Error;
|
||||||
|
|
||||||
|
pub mod charset;
|
||||||
|
pub mod entropy;
|
||||||
|
pub mod passphrase;
|
||||||
|
|
||||||
|
pub const MIN_LENGTH: usize = 4;
|
||||||
|
pub const MAX_LENGTH: usize = 128;
|
||||||
|
pub const MIN_WORDS: usize = 3;
|
||||||
|
pub const MAX_WORDS: usize = 12;
|
||||||
|
pub const RETRY_LIMIT: usize = 1000;
|
||||||
|
|
||||||
|
#[derive(Debug, Error, PartialEq, Eq)]
|
||||||
|
pub enum GenError {
|
||||||
|
#[error("length {got} is out of range {min}..={max}")]
|
||||||
|
InvalidLength { got: usize, min: usize, max: usize },
|
||||||
|
#[error("at least one character class must be enabled")]
|
||||||
|
NoCharacterClass,
|
||||||
|
#[error("word count {got} is out of range {min}..={max}")]
|
||||||
|
InvalidWordCount { got: usize, min: usize, max: usize },
|
||||||
|
#[error("could not satisfy character class constraints after {0} attempts")]
|
||||||
|
RetryLimitExceeded(usize),
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn errors_render_readable_messages() {
|
||||||
|
let err = GenError::InvalidLength {
|
||||||
|
got: 2,
|
||||||
|
min: MIN_LENGTH,
|
||||||
|
max: MAX_LENGTH,
|
||||||
|
};
|
||||||
|
assert_eq!(err.to_string(), "length 2 is out of range 4..=128");
|
||||||
|
assert_eq!(
|
||||||
|
GenError::NoCharacterClass.to_string(),
|
||||||
|
"at least one character class must be enabled"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,183 @@
|
|||||||
|
use crate::generator::{GenError, MAX_WORDS, MIN_WORDS};
|
||||||
|
use rand::RngExt;
|
||||||
|
use std::sync::LazyLock;
|
||||||
|
|
||||||
|
pub static WORDS: LazyLock<Vec<&'static str>> =
|
||||||
|
LazyLock::new(|| include_str!("wordlist.txt").lines().collect());
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct PassphraseOptions {
|
||||||
|
pub words: usize,
|
||||||
|
pub separator: char,
|
||||||
|
pub capitalize: bool,
|
||||||
|
pub add_number: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for PassphraseOptions {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
words: 5,
|
||||||
|
separator: '-',
|
||||||
|
capitalize: false,
|
||||||
|
add_number: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PassphraseOptions {
|
||||||
|
pub fn validate(&self) -> Result<(), GenError> {
|
||||||
|
if !(MIN_WORDS..=MAX_WORDS).contains(&self.words) {
|
||||||
|
return Err(GenError::InvalidWordCount {
|
||||||
|
got: self.words,
|
||||||
|
min: MIN_WORDS,
|
||||||
|
max: MAX_WORDS,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn generate<R: RngExt>(opts: &PassphraseOptions, rng: &mut R) -> Result<String, GenError> {
|
||||||
|
opts.validate()?;
|
||||||
|
|
||||||
|
let words: Vec<String> = (0..opts.words)
|
||||||
|
.map(|_| {
|
||||||
|
let word = WORDS[rng.random_range(0..WORDS.len())];
|
||||||
|
if opts.capitalize {
|
||||||
|
capitalize(word)
|
||||||
|
} else {
|
||||||
|
word.to_owned()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let mut out = words.join(&opts.separator.to_string());
|
||||||
|
if opts.add_number {
|
||||||
|
out.push(char::from_digit(rng.random_range(0..10), 10).unwrap_or('0'));
|
||||||
|
}
|
||||||
|
Ok(out)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn capitalize(word: &str) -> String {
|
||||||
|
let mut chars = word.chars();
|
||||||
|
match chars.next() {
|
||||||
|
Some(first) => first.to_uppercase().chain(chars).collect(),
|
||||||
|
None => String::new(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
|
fn rng() -> StdRng {
|
||||||
|
StdRng::seed_from_u64(7)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wordlist_has_the_expected_size() {
|
||||||
|
assert_eq!(WORDS.len(), 7776);
|
||||||
|
assert!(WORDS.iter().all(|w| !w.is_empty()));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Four wordlist entries contain a hyphen, so tests that split the output
|
||||||
|
// back into words use a separator that cannot occur inside a word.
|
||||||
|
const SPLITTABLE: char = '.';
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn produces_the_requested_word_count() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
words: 6,
|
||||||
|
separator: SPLITTABLE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert_eq!(out.split(SPLITTABLE).count(), 6);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn every_word_comes_from_the_list() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
separator: SPLITTABLE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(out.split(SPLITTABLE).all(|w| WORDS.contains(&w)), "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn joins_words_with_the_configured_separator() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
separator: SPLITTABLE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert_eq!(out.matches(SPLITTABLE).count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn defaults_to_a_hyphen_separator() {
|
||||||
|
let opts = PassphraseOptions::default();
|
||||||
|
assert_eq!(opts.separator, '-');
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(out.matches('-').count() >= 4, "{out}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn capitalizes_each_word_when_asked() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
capitalize: true,
|
||||||
|
separator: SPLITTABLE,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.split(SPLITTABLE)
|
||||||
|
.all(|w| w.chars().next().is_some_and(|c| c.is_ascii_uppercase())),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn appends_a_digit_when_asked() {
|
||||||
|
let opts = PassphraseOptions {
|
||||||
|
add_number: true,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
let out = generate(&opts, &mut rng()).unwrap();
|
||||||
|
assert!(
|
||||||
|
out.chars().last().is_some_and(|c| c.is_ascii_digit()),
|
||||||
|
"{out}"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_word_counts_outside_the_bounds() {
|
||||||
|
let too_few = PassphraseOptions {
|
||||||
|
words: 2,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
generate(&too_few, &mut rng()),
|
||||||
|
Err(GenError::InvalidWordCount {
|
||||||
|
got: 2,
|
||||||
|
min: MIN_WORDS,
|
||||||
|
max: MAX_WORDS
|
||||||
|
})
|
||||||
|
);
|
||||||
|
let too_many = PassphraseOptions {
|
||||||
|
words: 13,
|
||||||
|
..Default::default()
|
||||||
|
};
|
||||||
|
assert_eq!(
|
||||||
|
generate(&too_many, &mut rng()),
|
||||||
|
Err(GenError::InvalidWordCount {
|
||||||
|
got: 13,
|
||||||
|
min: MIN_WORDS,
|
||||||
|
max: MAX_WORDS
|
||||||
|
})
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because it is too large.
Load diff
+41
@@ -0,0 +1,41 @@
|
|||||||
|
mod cli;
|
||||||
|
mod clipboard;
|
||||||
|
mod generator;
|
||||||
|
mod run;
|
||||||
|
mod tui;
|
||||||
|
|
||||||
|
use anyhow::Context;
|
||||||
|
use clap::Parser;
|
||||||
|
use cli::{Cli, Command};
|
||||||
|
use std::io::Write;
|
||||||
|
use std::process::ExitCode;
|
||||||
|
|
||||||
|
fn main() -> ExitCode {
|
||||||
|
match dispatch() {
|
||||||
|
Ok(()) => ExitCode::SUCCESS,
|
||||||
|
Err(err) => {
|
||||||
|
eprintln!("error: {err:#}");
|
||||||
|
ExitCode::from(2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn dispatch() -> anyhow::Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
match cli.command {
|
||||||
|
Some(Command::Gen(args)) => {
|
||||||
|
let password = run::generate_once(&args.opts, &mut rand::rng())?;
|
||||||
|
writeln!(std::io::stdout(), "{password}").context("write password to stdout")?;
|
||||||
|
if args.copy
|
||||||
|
&& let Err(err) = clipboard::copy(&password)
|
||||||
|
{
|
||||||
|
eprintln!("warning: could not copy to clipboard: {err:#}");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Some(Command::Completions { shell }) => {
|
||||||
|
cli::write_completions(shell, &mut std::io::stdout()).context("write completion script")
|
||||||
|
}
|
||||||
|
None => tui::run(&cli.opts),
|
||||||
|
}
|
||||||
|
}
|
||||||
+58
@@ -0,0 +1,58 @@
|
|||||||
|
use crate::cli::{GenArgs, Mode};
|
||||||
|
use crate::generator::{GenError, charset, passphrase};
|
||||||
|
use rand::RngExt;
|
||||||
|
|
||||||
|
pub fn generate_once<R: RngExt>(args: &GenArgs, rng: &mut R) -> Result<String, GenError> {
|
||||||
|
match args.mode {
|
||||||
|
Mode::Charset => charset::generate(&args.charset_options(), rng),
|
||||||
|
Mode::Passphrase => passphrase::generate(&args.passphrase_options(), rng),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::{Cli, Mode};
|
||||||
|
use clap::Parser;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
|
fn args(argv: &[&str]) -> crate::cli::GenArgs {
|
||||||
|
Cli::parse_from(argv).opts
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn charset_mode_produces_a_password_of_the_requested_length() {
|
||||||
|
let out =
|
||||||
|
generate_once(&args(&["pasx", "-l", "20"]), &mut StdRng::seed_from_u64(1)).unwrap();
|
||||||
|
assert_eq!(out.chars().count(), 20);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn passphrase_mode_produces_the_requested_word_count() {
|
||||||
|
let a = args(&[
|
||||||
|
"pasx",
|
||||||
|
"--mode",
|
||||||
|
"passphrase",
|
||||||
|
"--words",
|
||||||
|
"4",
|
||||||
|
"--separator",
|
||||||
|
".",
|
||||||
|
]);
|
||||||
|
assert_eq!(a.mode, Mode::Passphrase);
|
||||||
|
let out = generate_once(&a, &mut StdRng::seed_from_u64(1)).unwrap();
|
||||||
|
assert_eq!(out.split('.').count(), 4);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn invalid_options_return_an_error() {
|
||||||
|
let out = generate_once(&args(&["pasx", "-l", "2"]), &mut StdRng::seed_from_u64(1));
|
||||||
|
assert!(out.is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn output_never_contains_a_trailing_newline() {
|
||||||
|
let out = generate_once(&args(&["pasx"]), &mut StdRng::seed_from_u64(1)).unwrap();
|
||||||
|
assert_eq!(out.trim_end(), out);
|
||||||
|
}
|
||||||
|
}
|
||||||
+415
@@ -0,0 +1,415 @@
|
|||||||
|
use crate::cli::{GenArgs, Mode};
|
||||||
|
use crate::generator::charset::{self, CharsetOptions};
|
||||||
|
use crate::generator::entropy::{self, Strength};
|
||||||
|
use crate::generator::passphrase::{self, PassphraseOptions};
|
||||||
|
use crate::generator::{MAX_LENGTH, MAX_WORDS, MIN_LENGTH, MIN_WORDS};
|
||||||
|
use rand::RngExt;
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const STATUS_TTL: Duration = Duration::from_secs(3);
|
||||||
|
const SEPARATORS: [char; 4] = ['-', '.', '_', ' '];
|
||||||
|
|
||||||
|
const CHARSET_FIELDS: [Field; 6] = [
|
||||||
|
Field::Length,
|
||||||
|
Field::Upper,
|
||||||
|
Field::Lower,
|
||||||
|
Field::Digits,
|
||||||
|
Field::Symbols,
|
||||||
|
Field::ExcludeAmbiguous,
|
||||||
|
];
|
||||||
|
|
||||||
|
const PASSPHRASE_FIELDS: [Field; 4] = [
|
||||||
|
Field::Words,
|
||||||
|
Field::Separator,
|
||||||
|
Field::Capitalize,
|
||||||
|
Field::AddNumber,
|
||||||
|
];
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Field {
|
||||||
|
Length,
|
||||||
|
Upper,
|
||||||
|
Lower,
|
||||||
|
Digits,
|
||||||
|
Symbols,
|
||||||
|
ExcludeAmbiguous,
|
||||||
|
Words,
|
||||||
|
Separator,
|
||||||
|
Capitalize,
|
||||||
|
AddNumber,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Field {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Field::Length => "Length",
|
||||||
|
Field::Upper => "Uppercase",
|
||||||
|
Field::Lower => "Lowercase",
|
||||||
|
Field::Digits => "Digits",
|
||||||
|
Field::Symbols => "Symbols",
|
||||||
|
Field::ExcludeAmbiguous => "Exclude look-alikes",
|
||||||
|
Field::Words => "Words",
|
||||||
|
Field::Separator => "Separator",
|
||||||
|
Field::Capitalize => "Capitalize",
|
||||||
|
Field::AddNumber => "Append digit",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Action {
|
||||||
|
NextField,
|
||||||
|
PrevField,
|
||||||
|
Increase,
|
||||||
|
Decrease,
|
||||||
|
Toggle,
|
||||||
|
Regenerate,
|
||||||
|
SwitchMode,
|
||||||
|
Quit,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Status {
|
||||||
|
pub message: String,
|
||||||
|
pub expires_at: Instant,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct App {
|
||||||
|
pub mode: Mode,
|
||||||
|
pub charset: CharsetOptions,
|
||||||
|
pub passphrase: PassphraseOptions,
|
||||||
|
pub output: String,
|
||||||
|
pub bits: f64,
|
||||||
|
pub selected: usize,
|
||||||
|
pub status: Option<Status>,
|
||||||
|
pub should_quit: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derived Debug would print `output`, which is the generated password. Redact
|
||||||
|
// it so a future `dbg!` or error chain does not leak it.
|
||||||
|
impl std::fmt::Debug for App {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
f.debug_struct("App")
|
||||||
|
.field("mode", &self.mode)
|
||||||
|
.field("charset", &self.charset)
|
||||||
|
.field("passphrase", &self.passphrase)
|
||||||
|
.field("output", &"<redacted>")
|
||||||
|
.field("bits", &self.bits)
|
||||||
|
.field("selected", &self.selected)
|
||||||
|
.field("status", &self.status)
|
||||||
|
.field("should_quit", &self.should_quit)
|
||||||
|
.finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl App {
|
||||||
|
pub fn new(args: &GenArgs, rng: &mut impl RngExt) -> Self {
|
||||||
|
let mut app = Self {
|
||||||
|
mode: args.mode,
|
||||||
|
charset: args.charset_options(),
|
||||||
|
passphrase: args.passphrase_options(),
|
||||||
|
output: String::new(),
|
||||||
|
bits: 0.0,
|
||||||
|
selected: 0,
|
||||||
|
status: None,
|
||||||
|
should_quit: false,
|
||||||
|
};
|
||||||
|
app.regenerate(rng);
|
||||||
|
app
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn fields(&self) -> &'static [Field] {
|
||||||
|
match self.mode {
|
||||||
|
Mode::Charset => &CHARSET_FIELDS,
|
||||||
|
Mode::Passphrase => &PASSPHRASE_FIELDS,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_field(&self) -> Field {
|
||||||
|
self.fields()[self.selected.min(self.fields().len() - 1)]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn strength(&self) -> Strength {
|
||||||
|
entropy::strength(self.bits)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_status(&mut self, message: impl Into<String>) {
|
||||||
|
self.status = Some(Status {
|
||||||
|
message: message.into(),
|
||||||
|
expires_at: Instant::now() + STATUS_TTL,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn expire_status(&mut self, now: Instant) {
|
||||||
|
if self.status.as_ref().is_some_and(|s| now >= s.expires_at) {
|
||||||
|
self.status = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&mut self, action: Action, rng: &mut impl RngExt) {
|
||||||
|
match action {
|
||||||
|
Action::Quit => self.should_quit = true,
|
||||||
|
Action::NextField => {
|
||||||
|
self.selected = (self.selected + 1) % self.fields().len();
|
||||||
|
}
|
||||||
|
Action::PrevField => {
|
||||||
|
let len = self.fields().len();
|
||||||
|
self.selected = (self.selected + len - 1) % len;
|
||||||
|
}
|
||||||
|
Action::SwitchMode => {
|
||||||
|
self.mode = match self.mode {
|
||||||
|
Mode::Charset => Mode::Passphrase,
|
||||||
|
Mode::Passphrase => Mode::Charset,
|
||||||
|
};
|
||||||
|
self.selected = 0;
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Action::Regenerate => self.regenerate(rng),
|
||||||
|
Action::Increase => self.step(1, rng),
|
||||||
|
Action::Decrease => self.step(-1, rng),
|
||||||
|
Action::Toggle => self.toggle(rng),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(&mut self, delta: i32, rng: &mut impl RngExt) {
|
||||||
|
match self.selected_field() {
|
||||||
|
Field::Length => {
|
||||||
|
self.charset.length =
|
||||||
|
clamp_step(self.charset.length, delta, MIN_LENGTH, MAX_LENGTH);
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::Words => {
|
||||||
|
self.passphrase.words =
|
||||||
|
clamp_step(self.passphrase.words, delta, MIN_WORDS, MAX_WORDS);
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::Separator => {
|
||||||
|
let current = SEPARATORS
|
||||||
|
.iter()
|
||||||
|
.position(|c| *c == self.passphrase.separator)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let len = SEPARATORS.len() as i32;
|
||||||
|
let next = ((current as i32 + delta).rem_euclid(len)) as usize;
|
||||||
|
self.passphrase.separator = SEPARATORS[next];
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
_ => self.toggle(rng),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn toggle(&mut self, rng: &mut impl RngExt) {
|
||||||
|
match self.selected_field() {
|
||||||
|
field @ (Field::Upper | Field::Lower | Field::Digits | Field::Symbols) => {
|
||||||
|
if self.charset.enabled_count() == 1 && self.is_enabled(field) {
|
||||||
|
self.set_status("at least one character class must stay enabled");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
match field {
|
||||||
|
Field::Upper => self.charset.upper = !self.charset.upper,
|
||||||
|
Field::Lower => self.charset.lower = !self.charset.lower,
|
||||||
|
Field::Digits => self.charset.digits = !self.charset.digits,
|
||||||
|
Field::Symbols => self.charset.symbols = !self.charset.symbols,
|
||||||
|
_ => unreachable!("matched only the four class fields above"),
|
||||||
|
}
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::ExcludeAmbiguous => {
|
||||||
|
self.charset.exclude_ambiguous = !self.charset.exclude_ambiguous;
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::Capitalize => {
|
||||||
|
self.passphrase.capitalize = !self.passphrase.capitalize;
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::AddNumber => {
|
||||||
|
self.passphrase.add_number = !self.passphrase.add_number;
|
||||||
|
self.regenerate(rng);
|
||||||
|
}
|
||||||
|
Field::Length | Field::Words | Field::Separator => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_enabled(&self, field: Field) -> bool {
|
||||||
|
match field {
|
||||||
|
Field::Upper => self.charset.upper,
|
||||||
|
Field::Lower => self.charset.lower,
|
||||||
|
Field::Digits => self.charset.digits,
|
||||||
|
Field::Symbols => self.charset.symbols,
|
||||||
|
_ => false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn regenerate(&mut self, rng: &mut impl RngExt) {
|
||||||
|
let generated = match self.mode {
|
||||||
|
Mode::Charset => charset::generate(&self.charset, rng),
|
||||||
|
Mode::Passphrase => passphrase::generate(&self.passphrase, rng),
|
||||||
|
};
|
||||||
|
match generated {
|
||||||
|
Ok(password) => {
|
||||||
|
self.output = password;
|
||||||
|
self.bits = match self.mode {
|
||||||
|
Mode::Charset => entropy::charset_bits(&self.charset),
|
||||||
|
Mode::Passphrase => entropy::passphrase_bits(&self.passphrase),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
Err(err) => {
|
||||||
|
// Never leave a password on screen that the visible settings
|
||||||
|
// did not produce: the entropy figure beside it would lie.
|
||||||
|
self.output.clear();
|
||||||
|
self.bits = 0.0;
|
||||||
|
self.set_status(err.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn clamp_step(value: usize, delta: i32, min: usize, max: usize) -> usize {
|
||||||
|
let next = value as i32 + delta;
|
||||||
|
next.clamp(min as i32, max as i32) as usize
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use clap::Parser;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
|
||||||
|
fn app() -> (App, StdRng) {
|
||||||
|
let mut rng = StdRng::seed_from_u64(3);
|
||||||
|
let args = Cli::parse_from(["pasx"]).opts;
|
||||||
|
let app = App::new(&args, &mut rng);
|
||||||
|
(app, rng)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_with_a_generated_password() {
|
||||||
|
let (app, _) = app();
|
||||||
|
assert_eq!(app.output.chars().count(), 18);
|
||||||
|
assert!(app.bits > 100.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn changing_length_regenerates_at_the_new_length() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.apply(Action::Increase, &mut rng);
|
||||||
|
assert_eq!(app.charset.length, 19);
|
||||||
|
assert_eq!(app.output.chars().count(), 19);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn length_clamps_at_both_bounds() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.charset.length = MAX_LENGTH;
|
||||||
|
app.apply(Action::Increase, &mut rng);
|
||||||
|
assert_eq!(app.charset.length, MAX_LENGTH);
|
||||||
|
|
||||||
|
app.charset.length = MIN_LENGTH;
|
||||||
|
app.apply(Action::Decrease, &mut rng);
|
||||||
|
assert_eq!(app.charset.length, MIN_LENGTH);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn the_last_enabled_class_cannot_be_turned_off() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.charset.lower = false;
|
||||||
|
app.charset.digits = false;
|
||||||
|
app.charset.symbols = false;
|
||||||
|
|
||||||
|
app.selected = app
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == Field::Upper)
|
||||||
|
.unwrap();
|
||||||
|
app.apply(Action::Toggle, &mut rng);
|
||||||
|
|
||||||
|
assert!(app.charset.upper, "the last class was turned off");
|
||||||
|
assert!(app.status.is_some(), "no explanation was shown");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn switching_mode_regenerates_a_passphrase() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.apply(Action::SwitchMode, &mut rng);
|
||||||
|
assert_eq!(app.mode, Mode::Passphrase);
|
||||||
|
assert_eq!(app.passphrase.words, 5);
|
||||||
|
assert!(app.output.matches('-').count() >= 4, "{}", app.output);
|
||||||
|
assert_eq!(app.selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn field_selection_wraps_around() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
let last = app.fields().len() - 1;
|
||||||
|
app.apply(Action::PrevField, &mut rng);
|
||||||
|
assert_eq!(app.selected, last);
|
||||||
|
app.apply(Action::NextField, &mut rng);
|
||||||
|
assert_eq!(app.selected, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn regenerate_produces_a_different_password() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
let first = app.output.clone();
|
||||||
|
app.apply(Action::Regenerate, &mut rng);
|
||||||
|
assert_ne!(app.output, first);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_failed_generation_clears_the_stale_output() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(5);
|
||||||
|
let args = Cli::parse_from([
|
||||||
|
"pasx",
|
||||||
|
"--no-upper",
|
||||||
|
"--no-lower",
|
||||||
|
"--no-digits",
|
||||||
|
"--no-symbols",
|
||||||
|
])
|
||||||
|
.opts;
|
||||||
|
let mut app = App::new(&args, &mut rng);
|
||||||
|
|
||||||
|
assert!(app.output.is_empty());
|
||||||
|
assert_eq!(app.bits, 0.0);
|
||||||
|
assert!(app.status.is_some());
|
||||||
|
|
||||||
|
app.selected = app
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == Field::Lower)
|
||||||
|
.unwrap();
|
||||||
|
app.apply(Action::Toggle, &mut rng);
|
||||||
|
assert!(app.charset.lower);
|
||||||
|
assert_eq!(app.output.chars().count(), 18);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn quit_sets_the_flag() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.apply(Action::Quit, &mut rng);
|
||||||
|
assert!(app.should_quit);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn status_messages_expire() {
|
||||||
|
let (mut app, _) = app();
|
||||||
|
app.set_status("copied");
|
||||||
|
assert!(app.status.is_some());
|
||||||
|
app.expire_status(Instant::now() + Duration::from_secs(5));
|
||||||
|
assert!(app.status.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn separator_cycles_through_the_allowed_set() {
|
||||||
|
let (mut app, mut rng) = app();
|
||||||
|
app.apply(Action::SwitchMode, &mut rng);
|
||||||
|
app.selected = app
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.position(|f| *f == Field::Separator)
|
||||||
|
.unwrap();
|
||||||
|
app.apply(Action::Increase, &mut rng);
|
||||||
|
assert_eq!(app.passphrase.separator, '.');
|
||||||
|
assert!(app.output.contains('.'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
use crate::tui::app::Action;
|
||||||
|
use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Intent {
|
||||||
|
Act(Action),
|
||||||
|
Copy,
|
||||||
|
Ignore,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only Ctrl is inspected: an Alt or Shift chord over a mapped key is treated
|
||||||
|
// as the bare key rather than swallowed.
|
||||||
|
pub fn map_key(key: KeyEvent) -> Intent {
|
||||||
|
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
||||||
|
return match key.code {
|
||||||
|
KeyCode::Char('c') => Intent::Act(Action::Quit),
|
||||||
|
_ => Intent::Ignore,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
match key.code {
|
||||||
|
KeyCode::Down => Intent::Act(Action::NextField),
|
||||||
|
KeyCode::Up => Intent::Act(Action::PrevField),
|
||||||
|
KeyCode::Right => Intent::Act(Action::Increase),
|
||||||
|
KeyCode::Left => Intent::Act(Action::Decrease),
|
||||||
|
KeyCode::Char(' ') | KeyCode::Enter => Intent::Act(Action::Toggle),
|
||||||
|
KeyCode::Tab => Intent::Act(Action::SwitchMode),
|
||||||
|
KeyCode::Char('r') => Intent::Act(Action::Regenerate),
|
||||||
|
KeyCode::Char('c') => Intent::Copy,
|
||||||
|
KeyCode::Char('q') | KeyCode::Esc => Intent::Act(Action::Quit),
|
||||||
|
_ => Intent::Ignore,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn key(code: KeyCode) -> KeyEvent {
|
||||||
|
KeyEvent::new(code, KeyModifiers::NONE)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arrows_move_and_adjust() {
|
||||||
|
assert_eq!(map_key(key(KeyCode::Down)), Intent::Act(Action::NextField));
|
||||||
|
assert_eq!(map_key(key(KeyCode::Up)), Intent::Act(Action::PrevField));
|
||||||
|
assert_eq!(map_key(key(KeyCode::Right)), Intent::Act(Action::Increase));
|
||||||
|
assert_eq!(map_key(key(KeyCode::Left)), Intent::Act(Action::Decrease));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn space_and_enter_toggle_and_tab_switches_mode() {
|
||||||
|
assert_eq!(
|
||||||
|
map_key(key(KeyCode::Char(' '))),
|
||||||
|
Intent::Act(Action::Toggle)
|
||||||
|
);
|
||||||
|
assert_eq!(map_key(key(KeyCode::Enter)), Intent::Act(Action::Toggle));
|
||||||
|
assert_eq!(map_key(key(KeyCode::Tab)), Intent::Act(Action::SwitchMode));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn letters_trigger_regenerate_and_copy() {
|
||||||
|
assert_eq!(
|
||||||
|
map_key(key(KeyCode::Char('r'))),
|
||||||
|
Intent::Act(Action::Regenerate)
|
||||||
|
);
|
||||||
|
assert_eq!(map_key(key(KeyCode::Char('c'))), Intent::Copy);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn q_escape_and_ctrl_c_all_quit() {
|
||||||
|
assert_eq!(map_key(key(KeyCode::Char('q'))), Intent::Act(Action::Quit));
|
||||||
|
assert_eq!(map_key(key(KeyCode::Esc)), Intent::Act(Action::Quit));
|
||||||
|
assert_eq!(
|
||||||
|
map_key(KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)),
|
||||||
|
Intent::Act(Action::Quit)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unknown_keys_are_ignored() {
|
||||||
|
assert_eq!(map_key(key(KeyCode::Char('z'))), Intent::Ignore);
|
||||||
|
assert_eq!(map_key(key(KeyCode::F(5))), Intent::Ignore);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,64 @@
|
|||||||
|
pub mod app;
|
||||||
|
pub mod input;
|
||||||
|
pub mod render;
|
||||||
|
|
||||||
|
use crate::cli::GenArgs;
|
||||||
|
use crate::clipboard;
|
||||||
|
use crate::tui::app::App;
|
||||||
|
use crate::tui::input::{Intent, map_key};
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use ratatui::crossterm::event::{self, Event, KeyEventKind};
|
||||||
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
|
const POLL_INTERVAL: Duration = Duration::from_millis(250);
|
||||||
|
|
||||||
|
pub fn run(args: &GenArgs) -> Result<()> {
|
||||||
|
let mut rng = rand::rng();
|
||||||
|
let mut app = App::new(args, &mut rng);
|
||||||
|
|
||||||
|
// ratatui::run's init() panics (via expect) when there is no usable
|
||||||
|
// terminal, e.g. run under cron/systemd/CI with no controlling tty. Use
|
||||||
|
// the fallible try_init/try_restore pair instead, and restore on every
|
||||||
|
// exit path — including an Err from the loop body — since that is what
|
||||||
|
// ratatui::run was doing for us.
|
||||||
|
let mut terminal = ratatui::try_init().context("failed to initialize terminal")?;
|
||||||
|
let result = event_loop(&mut terminal, &mut app, &mut rng);
|
||||||
|
ratatui::try_restore().context("failed to restore terminal")?;
|
||||||
|
result
|
||||||
|
}
|
||||||
|
|
||||||
|
fn event_loop(
|
||||||
|
terminal: &mut ratatui::DefaultTerminal,
|
||||||
|
app: &mut App,
|
||||||
|
rng: &mut impl rand::RngExt,
|
||||||
|
) -> Result<()> {
|
||||||
|
while !app.should_quit {
|
||||||
|
terminal.draw(|frame| render::draw(frame, app))?;
|
||||||
|
|
||||||
|
if event::poll(POLL_INTERVAL)?
|
||||||
|
&& let Event::Key(key) = event::read()?
|
||||||
|
&& key.kind == KeyEventKind::Press
|
||||||
|
{
|
||||||
|
match map_key(key) {
|
||||||
|
Intent::Act(action) => app.apply(action, rng),
|
||||||
|
Intent::Copy => {
|
||||||
|
if app.output.is_empty() {
|
||||||
|
app.set_status("nothing to copy");
|
||||||
|
} else {
|
||||||
|
match clipboard::copy(&app.output) {
|
||||||
|
Ok(()) => app.set_status("copied to clipboard"),
|
||||||
|
// Alternate formatting prints anyhow's full context
|
||||||
|
// chain (e.g. the tool's stderr), not just the
|
||||||
|
// outermost "<tool> failed" context.
|
||||||
|
Err(err) => app.set_status(format!("clipboard unavailable: {err:#}")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Intent::Ignore => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
app.expire_status(Instant::now());
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -0,0 +1,287 @@
|
|||||||
|
use crate::cli::Mode;
|
||||||
|
use crate::tui::app::{App, Field};
|
||||||
|
use ratatui::Frame;
|
||||||
|
use ratatui::layout::{Constraint, Layout, Rect};
|
||||||
|
use ratatui::style::{Modifier, Style, Stylize};
|
||||||
|
use ratatui::text::{Line, Span};
|
||||||
|
use ratatui::widgets::{Block, Borders, Gauge, List, ListItem, ListState, Paragraph, Wrap};
|
||||||
|
|
||||||
|
pub const MIN_WIDTH: u16 = 40;
|
||||||
|
pub const MIN_HEIGHT: u16 = 14;
|
||||||
|
|
||||||
|
const GAUGE_CEILING: f64 = 128.0;
|
||||||
|
|
||||||
|
// Rows the gauge, settings, footer and the password block's own borders need,
|
||||||
|
// so the password can claim whatever is left.
|
||||||
|
const RESERVED_ROWS: u16 = 13;
|
||||||
|
|
||||||
|
pub fn draw(frame: &mut Frame, app: &App) {
|
||||||
|
let area = frame.area();
|
||||||
|
if area.width < MIN_WIDTH || area.height < MIN_HEIGHT {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new("terminal too small").block(Block::default().borders(Borders::ALL)),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let (needed, available) = password_rows(app, area);
|
||||||
|
let truncated = needed > available;
|
||||||
|
|
||||||
|
let [password_area, gauge_area, settings_area, footer_area] = Layout::vertical([
|
||||||
|
Constraint::Length(needed.min(available) + 2),
|
||||||
|
Constraint::Length(3),
|
||||||
|
Constraint::Min(5),
|
||||||
|
Constraint::Length(3),
|
||||||
|
])
|
||||||
|
.areas(area);
|
||||||
|
|
||||||
|
draw_password(frame, app, password_area, truncated);
|
||||||
|
draw_gauge(frame, app, gauge_area);
|
||||||
|
draw_settings(frame, app, settings_area);
|
||||||
|
draw_footer(frame, app, footer_area);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The rows the whole password would need to display, and the rows actually
|
||||||
|
// available in the full frame. A 128-character password does not fit on one
|
||||||
|
// line in a narrow terminal; when `needed` exceeds `available` the block is
|
||||||
|
// too small to show the whole password and the title must say so — nothing
|
||||||
|
// else on screen would tell the user their password was cut off.
|
||||||
|
fn password_rows(app: &App, area: Rect) -> (u16, u16) {
|
||||||
|
let inner_width = (area.width.saturating_sub(2) as usize).max(1);
|
||||||
|
let needed = (app.output.chars().count().div_ceil(inner_width)).max(1) as u16;
|
||||||
|
let available = area.height.saturating_sub(RESERVED_ROWS).max(1);
|
||||||
|
(needed, available)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_password(frame: &mut Frame, app: &App, area: Rect, truncated: bool) {
|
||||||
|
let title = if truncated {
|
||||||
|
" pasx \u{2014} truncated "
|
||||||
|
} else {
|
||||||
|
" pasx "
|
||||||
|
};
|
||||||
|
let block = Block::default().borders(Borders::ALL).title(title);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(app.output.as_str())
|
||||||
|
.bold()
|
||||||
|
.wrap(Wrap { trim: false })
|
||||||
|
.block(block),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_gauge(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let ratio = (app.bits / GAUGE_CEILING).clamp(0.0, 1.0);
|
||||||
|
let label = format!("entropy: {:.0} bit - {}", app.bits, app.strength().label());
|
||||||
|
frame.render_widget(
|
||||||
|
Gauge::default()
|
||||||
|
.block(Block::default().borders(Borders::ALL))
|
||||||
|
.ratio(ratio)
|
||||||
|
.label(label),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_settings(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
let title = match app.mode {
|
||||||
|
Mode::Charset => " Mode: [characters] passphrase ",
|
||||||
|
Mode::Passphrase => " Mode: characters [passphrase] ",
|
||||||
|
};
|
||||||
|
|
||||||
|
let items: Vec<ListItem> = app
|
||||||
|
.fields()
|
||||||
|
.iter()
|
||||||
|
.map(|field| {
|
||||||
|
let line = Line::from(vec![
|
||||||
|
Span::raw(format!("{:<22}", field.label())),
|
||||||
|
Span::raw(value_of(app, *field)),
|
||||||
|
]);
|
||||||
|
ListItem::new(line)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let list = List::new(items)
|
||||||
|
.block(Block::default().borders(Borders::ALL).title(title))
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.highlight_style(Style::default().add_modifier(Modifier::REVERSED));
|
||||||
|
|
||||||
|
// A stateful render lets ratatui scroll the viewport to keep the
|
||||||
|
// selection visible; a plain widget always starts at offset 0 and a
|
||||||
|
// selection past the visible rows looks like the UI has frozen.
|
||||||
|
let mut state = ListState::default().with_selected(Some(app.selected));
|
||||||
|
frame.render_stateful_widget(list, area, &mut state);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_footer(frame: &mut Frame, app: &App, area: Rect) {
|
||||||
|
// Kept comfortably under 80 columns so it never clips off the "q quit"
|
||||||
|
// hint, which a full-screen app must never hide.
|
||||||
|
let hints = "\u{2191}\u{2193} select \u{2190}\u{2192} change space toggle r regen c copy tab mode q quit";
|
||||||
|
let status = app
|
||||||
|
.status
|
||||||
|
.as_ref()
|
||||||
|
.map(|s| s.message.as_str())
|
||||||
|
.unwrap_or("");
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(vec![Line::from(status), Line::from(hints)]).wrap(Wrap { trim: false }),
|
||||||
|
area,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
fn value_of(app: &App, field: Field) -> String {
|
||||||
|
let on_off = |on: bool| {
|
||||||
|
if on {
|
||||||
|
"[x]".to_string()
|
||||||
|
} else {
|
||||||
|
"[ ]".to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match field {
|
||||||
|
Field::Length => format!("< {} >", app.charset.length),
|
||||||
|
Field::Upper => on_off(app.charset.upper),
|
||||||
|
Field::Lower => on_off(app.charset.lower),
|
||||||
|
Field::Digits => on_off(app.charset.digits),
|
||||||
|
Field::Symbols => on_off(app.charset.symbols),
|
||||||
|
Field::ExcludeAmbiguous => on_off(app.charset.exclude_ambiguous),
|
||||||
|
Field::Words => format!("< {} >", app.passphrase.words),
|
||||||
|
Field::Separator => format!("< {:?} >", app.passphrase.separator),
|
||||||
|
Field::Capitalize => on_off(app.passphrase.capitalize),
|
||||||
|
Field::AddNumber => on_off(app.passphrase.add_number),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::cli::Cli;
|
||||||
|
use crate::tui::app::App;
|
||||||
|
use clap::Parser;
|
||||||
|
use rand::SeedableRng;
|
||||||
|
use rand::rngs::StdRng;
|
||||||
|
use ratatui::Terminal;
|
||||||
|
use ratatui::backend::TestBackend;
|
||||||
|
|
||||||
|
fn render_at(width: u16, height: u16) -> String {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
|
terminal.draw(|frame| draw(frame, &app)).unwrap();
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_the_password_and_the_settings() {
|
||||||
|
let screen = render_at(80, 24);
|
||||||
|
assert!(screen.contains("pasx"), "{screen}");
|
||||||
|
assert!(screen.contains("Length"), "{screen}");
|
||||||
|
assert!(screen.contains("Symbols"), "{screen}");
|
||||||
|
assert!(screen.contains("entropy"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_a_message_instead_of_a_broken_layout_when_too_small() {
|
||||||
|
let screen = render_at(20, 6);
|
||||||
|
assert!(screen.contains("too small"), "{screen}");
|
||||||
|
assert!(!screen.contains("Length"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn wraps_a_long_password_instead_of_clipping_it() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let args = Cli::parse_from(["pasx", "--length", "120"]).opts;
|
||||||
|
let app = App::new(&args, &mut rng);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(60, 24)).unwrap();
|
||||||
|
terminal.draw(|frame| draw(frame, &app)).unwrap();
|
||||||
|
let screen: String = terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// 120 chars at 58 usable columns wraps to three lines (58+58+4), each
|
||||||
|
// framed by its own left/right border in the flattened buffer, so a
|
||||||
|
// single contiguous "everything after the first line" slice can never
|
||||||
|
// match; assert every wrapped chunk lands on screen instead.
|
||||||
|
let inner_width = 58;
|
||||||
|
let chars: Vec<char> = app.output.chars().collect();
|
||||||
|
for chunk in chars.chunks(inner_width) {
|
||||||
|
let chunk: String = chunk.iter().collect();
|
||||||
|
assert!(screen.contains(&chunk), "{screen}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_passphrase_mode_without_panicking() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let mut app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
|
||||||
|
app.apply(crate::tui::app::Action::SwitchMode, &mut rng);
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(80, 24)).unwrap();
|
||||||
|
terminal.draw(|frame| draw(frame, &app)).unwrap();
|
||||||
|
let screen: String = terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect();
|
||||||
|
assert!(screen.contains("Words"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_app(app: &App, width: u16, height: u16) -> String {
|
||||||
|
let mut terminal = Terminal::new(TestBackend::new(width, height)).unwrap();
|
||||||
|
terminal.draw(|frame| draw(frame, app)).unwrap();
|
||||||
|
terminal
|
||||||
|
.backend()
|
||||||
|
.buffer()
|
||||||
|
.content()
|
||||||
|
.iter()
|
||||||
|
.map(|cell| cell.symbol())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn selecting_the_last_field_scrolls_it_into_view() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let mut app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
|
||||||
|
// At 40x14 the settings list has room for only a few rows, so a plain
|
||||||
|
// (stateless) List would leave the last field selected but scrolled
|
||||||
|
// out of view. Walk selection to the last charset field and confirm
|
||||||
|
// it is still rendered.
|
||||||
|
let last = app.fields().len() - 1;
|
||||||
|
for _ in 0..last {
|
||||||
|
app.apply(crate::tui::app::Action::NextField, &mut rng);
|
||||||
|
}
|
||||||
|
assert_eq!(app.selected, last);
|
||||||
|
|
||||||
|
let screen = render_app(&app, MIN_WIDTH, MIN_HEIGHT);
|
||||||
|
assert!(screen.contains(app.selected_field().label()), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn footer_shows_the_quit_hint_at_eighty_columns() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let app = App::new(&Cli::parse_from(["pasx"]).opts, &mut rng);
|
||||||
|
let screen = render_app(&app, 80, 24);
|
||||||
|
assert!(screen.contains("q quit"), "{screen}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn password_block_is_marked_truncated_when_it_does_not_fit() {
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let cramped = App::new(&Cli::parse_from(["pasx", "--length", "128"]).opts, &mut rng);
|
||||||
|
let screen = render_app(&cramped, MIN_WIDTH, MIN_HEIGHT);
|
||||||
|
assert!(screen.contains("truncated"), "{screen}");
|
||||||
|
|
||||||
|
let mut rng = StdRng::seed_from_u64(11);
|
||||||
|
let roomy = App::new(&Cli::parse_from(["pasx", "--length", "128"]).opts, &mut rng);
|
||||||
|
let screen = render_app(&roomy, 80, 40);
|
||||||
|
assert!(!screen.contains("truncated"), "{screen}");
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user