diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..14ea869 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,51 @@ +name: Multi-platform Test + +on: + push: + branches: [ main ] + paths: + - 'src/**' + - 'tests/**' + - 'Cargo.toml' + pull_request: + branches: [ main ] + paths: + - 'src/**' + - 'tests/**' + - 'Cargo.toml' + +jobs: + build-and-test: + runs-on: ${{ matrix.os }} + strategy: + matrix: + os: [ubuntu-latest, windows-latest, macos-latest] + + steps: + - uses: actions/checkout@v4 + + - name: Install Rust + run: | + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + shell: bash + + - id: versions + name: Print versions + shell: bash + run: | + rustup --version + rustc --version + cargo --version + + - name: Build release + run: cargo build --release + + - name: Run tests + run: cargo test --release + + - name: Test installation + run: | + cargo install --path . + pwdg --version + pwdg -l 16 + pwdg -h diff --git a/.github/workflows/static_analysis.yml b/.github/workflows/static_analysis.yml new file mode 100644 index 0000000..e2871e7 --- /dev/null +++ b/.github/workflows/static_analysis.yml @@ -0,0 +1,50 @@ +name: Static Analysis + +on: + push: + branches: [ main ] + paths: + - 'src/**' + - 'tests/**' + - 'Cargo.toml' + pull_request: + branches: [ main ] + paths: + - 'src/**' + - 'tests/**' + - 'Cargo.toml' + +jobs: + lint-and-audit: + runs-on: ubuntu-latest + + steps: + - uses: actions/checkout@v4 + + - name: Setup Rust Environment + run: | + # Install rustup + curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + . "$HOME/.cargo/env" + # Install Clippy and rustfmt + rustup component add clippy rustfmt + # Install cargo-audit + cargo install cargo-audit + + - name: Print versions + run: | + rustup --version + rustc --version + cargo --version + cargo clippy --version + cargo fmt --version + cargo audit --version + + - name: Run clippy (lint) + run: cargo clippy -- -D warnings + + - name: Check Code Formatting with rustfmt + run: cargo fmt -- --check + + - name: Security Audit with cargo-audit + run: cargo audit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..097fd31 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,289 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "0.6.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96bd03f33fe50a863e394ee9718a706f988b9079b20c3784fb726e7678b62fb" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8901269c6307e8d93993578286ac0edf7f195079ffff5ebdeea6a59ffb7e36bc" + +[[package]] +name = "anstyle-parse" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c75ac65da39e5fe5ab759307499ddad880d724eed2f6ce5b5e8a26f4f387928c" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e28923312444cdd728e4738b3f9c9cac739500909bb3d3c94b43551b16517648" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cd54b81ec8d6180e24654d0b371ad22fc3dd083b6ff8ba325b72e00c87660a7" +dependencies = [ + "anstyle", + "windows-sys", +] + +[[package]] +name = "cfg-if" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf1de4339761588bc0619e3cbc0120ee582ebb74b53b4efbf79117bd2da40fd" + +[[package]] +name = "clap" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90bc066a67923782aa8515dbaea16946c5bcc5addbd668bb80af688e53e548a0" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae129e2e766ae0ec03484e609954119f123cc1fe650337e155d03b022f24f7b4" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "528131438037fd55894f62d6e9f068b8f45ac57ffa77517819645d10aed04f64" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "clap_lex" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "98cc8fbded0c607b7ba9dd60cd98df59af97e84d24e49c8557331cfc26d301ce" + +[[package]] +name = "colorchoice" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "acbf1af155f9b9ef647e42cdc158db4b64a1b61f743629225fde6f3e0be2a7c7" + +[[package]] +name = "getrandom" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "190092ea657667030ac6a35e305e62fc4dd69fd98ac98631e5d3a2b1575a12b5" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "libc" +version = "0.2.153" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c198f91728a82281a64e1f4f9eeb25d82cb32a5de251c6bd1b5154d63a8e7bd" + +[[package]] +name = "ppv-lite86" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b40af805b3121feab8a3c29f04d8ad262fa8e0561883e7653e024ae4479e6de" + +[[package]] +name = "proc-macro2" +version = "1.0.79" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835ff2298f5721608eb1a980ecaee1aef2c132bf95ecc026a11b7bf3c01c02e" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pwdg" +version = "0.1.0" +dependencies = [ + "clap", + "rand", +] + +[[package]] +name = "quote" +version = "1.0.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291ec9ab5efd934aaf503a6466c5d5251535d108ee747472c3977cc5acc868ef" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "rand" +version = "0.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom", +] + +[[package]] +name = "strsim" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ee073c9e4cd00e28217186dbe12796d692868f432bf2e97ee73bed0c56dfa01" + +[[package]] +name = "syn" +version = "2.0.58" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44cfb93f38070beee36b3fef7d4f5a16f27751d94b187b666a5cc5e9b0d30687" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "unicode-ident" +version = "1.0.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3354b9ac3fae1ff6755cb6db53683adb661634f67557942dea4facebec0fee4b" + +[[package]] +name = "utf8parse" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "711b9620af191e0cdc7468a8d14e709c3dcdb115b36f838e601583af800a370a" + +[[package]] +name = "wasi" +version = "0.11.0+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c8d87e72b64a3b4db28d11ce29237c246188f4f51057d65a7eab63b7987e423" + +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets", +] + +[[package]] +name = "windows-targets" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7dd37b7e5ab9018759f893a1952c9420d060016fc19a472b4bb20d1bdd694d1b" +dependencies = [ + "windows_aarch64_gnullvm", + "windows_aarch64_msvc", + "windows_i686_gnu", + "windows_i686_msvc", + "windows_x86_64_gnu", + "windows_x86_64_gnullvm", + "windows_x86_64_msvc", +] + +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bcf46cf4c365c6f2d1cc93ce535f2c8b244591df96ceee75d8e83deb70a9cac9" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da9f259dd3bcf6990b55bffd094c4f7235817ba4ceebde8e6d11cd0c5633b675" + +[[package]] +name = "windows_i686_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b474d8268f99e0995f25b9f095bc7434632601028cf86590aea5c8a5cb7801d3" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1515e9a29e5bed743cb4415a9ecf5dfca648ce85ee42e15873c3cd8610ff8e02" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5eee091590e89cc02ad514ffe3ead9eb6b660aedca2183455434b93546371a03" + +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ca79f2451b49fa9e2af39f0747fe999fcda4f5e241b2898624dca97a1f2177" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32b752e52a2da0ddfbdbcc6fceadfeede4c939ed16d13e648833a61dfb611ed8" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..abd2a83 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "pwdg" +version = "0.1.0" +edition = "2021" +license = "Apache-2.0" +authors = ["Owain Davies"] +description = """A rudimentary command-line tool and Rust library for \ +generating secure, random passwords.""" +repository = "https://github.com/OTheDev/pwdg" +keywords = ["password", "security", "cli"] +categories = ["command-line-utilities", "cryptography"] +readme = "README.md" + +[dependencies] +clap = { version = "4.5", features = ["derive"] } +rand = "0.8" diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + 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. diff --git a/README.md b/README.md new file mode 100644 index 0000000..61ef548 --- /dev/null +++ b/README.md @@ -0,0 +1,104 @@ +[![](https://github.com/OTheDev/pwdg/actions/workflows/ci.yml/badge.svg)](https://github.com/OTheDev/pwdg/actions/workflows/ci.yml) +[![](https://github.com/OTheDev/pwdg/actions/workflows/static_analysis.yml/badge.svg)](https://github.com/OTheDev/pwdg/actions/workflows/static_analysis.yml) + +# pwdg + +`pwdg` is a rudimentary command-line tool and Rust library for generating +secure, random passwords. + +# Installation + +Install `pwdg` using `cargo`: + +```shell +cargo install pwdg +``` + +# Usage + +## Command Line Interface + +Generate a password with default settings: + +```shell +pwdg +``` + +Generate a password with at least 1 uppercase letter, 1 lowercase letter, 1 +digit, and 1 special character: + +```shell +pwdg -s +``` + +Generate a 12-character password with at least 2 uppercase letters, 2 lowercase +letters, 2 digits, and 2 special characters: + +```shell +pwdg --length 12 --min-upper 2 --min-lower 2 --min-digit 2 --min-special 2 +``` + +Generate a password with default settings, but excluding the characters `A`, +`B`, `C`, `D`, and `E` from the overall character set used for password +generation: + +```shell +pwdg --exclude=ABCDE +``` + +### Command Line Options + +```console +$ pwdg --help +A rudimentary command-line tool and Rust library for generating secure, random passwords. + +Usage: pwdg [OPTIONS] + +Options: + -l, --length Sets the length of the password. Must be at least 8 [default: 8] + --min-upper Minimum number of uppercase characters (A to Z) [default: 0] + --min-lower Minimum number of lowercase characters (a to z) [default: 0] + --min-digit Minimum number of digit characters (0 to 9) [default: 0] + --min-special Minimum number of special characters. + Special characters: !@#$%^&*()_+-={}[]|:;"'<>,.?/~\` [default: 0] + -e, --exclude Characters to exclude from the overall character set used for password generation + -s, --strong Generates a password with at least 1 uppercase letter, 1 lowercase letter, 1 digit, and 1 special character. This option overrides --min-upper, --min-lower, --min-digit, and --min-special if they are also set + -h, --help Print help + -V, --version Print version +``` + +## Characters + +Passwords may be comprised of **uppercase** (`A` to `Z`), **lowercase** (`a` to +`z`), **digit** (`0` to `9`), or **special** characters. + +The set of special characters: +```plaintext +! @ # $ % ^ & * ( ) _ + - = { } [ ] | : ; " ' < > , . ? / ~ \ ` +``` + +# License + +`pwdg` is licensed under the [Apache License, Version 2.0]( + https://github.com/OTheDev/pwdg/blob/main/LICENSE). + +# Source + +Clone the [repository](https://github.com/OTheDev/pwdg): + +```shell +git clone git@github.com:OTheDev/pwdg.git +cd pwdg +``` + +## Test + +```shell +cargo test # Optionally, --release +``` + +## Install + +```shell +cargo install --path . +``` diff --git a/rustfmt.toml b/rustfmt.toml new file mode 100644 index 0000000..205c72c --- /dev/null +++ b/rustfmt.toml @@ -0,0 +1,2 @@ +tab_spaces = 2 +max_width = 80 diff --git a/src/charset.rs b/src/charset.rs new file mode 100644 index 0000000..2addead --- /dev/null +++ b/src/charset.rs @@ -0,0 +1,9 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +pub const SPECIAL_CHARS: &[char] = &[ + '!', '@', '#', '$', '%', '^', '&', '*', '(', ')', '_', '+', '-', '=', '{', + '}', '[', ']', '|', ':', ';', '"', '\'', '<', '>', ',', '.', '?', '/', '~', + '\\', '`', +]; diff --git a/src/error.rs b/src/error.rs new file mode 100644 index 0000000..3cd3a3e --- /dev/null +++ b/src/error.rs @@ -0,0 +1,81 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +use crate::MIN_LENGTH; + +#[derive(Debug)] +pub enum Error { + /// Specified length is less than `MIN_LENGTH`. + Length, + /// Sum of the minimum character requirements exceeds the length. + MinLimitExceeded, + /// There exists a category (upper, lower, digit, or special) such that the + /// number of characters in that category is less than any minimum specified + /// for that category, after applying any exclusions. + InsufficientCharacters(&'static str), +} + +impl std::error::Error for Error {} + +impl std::fmt::Display for Error { + fn fmt(&self, f: &mut std::fmt::Formatter) -> std::fmt::Result { + match self { + Error::Length => { + write!( + f, + "Password length must be at least {} characters. [Error::Length]", + MIN_LENGTH + ) + } + Error::MinLimitExceeded => { + write!( + f, + concat!( + "Sum of minimum character requirements exceeds password length. ", + "[Error::MinLimitExceeded]" + ) + ) + } + Error::InsufficientCharacters(char_type) => { + write!( + f, + concat!( + "Insufficient characters available for {}. ", + "[Error::InsufficientCharacters]" + ), + char_type + ) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_length_error_display() { + let error = Error::Length; + assert!(format!("{}", error).contains(&format!( + "Password length must be at least {} characters.", + MIN_LENGTH + ))); + } + + #[test] + fn test_min_limit_exceeded_error_display() { + let error = Error::MinLimitExceeded; + assert!(format!("{}", error).contains( + "Sum of minimum character requirements exceeds password length." + )); + } + + #[test] + fn test_insufficient_characters_error_display() { + let error = Error::InsufficientCharacters("upper"); + assert!(format!("{}", error) + .contains("Insufficient characters available for upper")); + } +} diff --git a/src/generator.rs b/src/generator.rs new file mode 100644 index 0000000..3fc5584 --- /dev/null +++ b/src/generator.rs @@ -0,0 +1,437 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +use rand::{rngs::OsRng, seq::SliceRandom}; +use std::collections::HashSet; + +use crate::util::checked_sum; +use crate::util::filtered_range; +use crate::Error; +use crate::SPECIAL_CHARS; + +pub const MIN_LENGTH: usize = 8; +pub const DEFAULT_PWDGEN_OPTIONS: PwdGenOptions = PwdGenOptions::default_(); + +/// Configuration options for a password generator. +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct PwdGenOptions<'a> { + pub min_upper: usize, + pub min_lower: usize, + pub min_digit: usize, + pub min_special: usize, + pub exclude: Option<&'a str>, +} + +impl<'a> PwdGenOptions<'a> { + const fn default_() -> Self { + PwdGenOptions { + min_upper: 0, + min_lower: 0, + min_digit: 0, + min_special: 0, + exclude: None, + } + } +} + +impl<'a> Default for PwdGenOptions<'a> { + /// Default constructor for `PwdGenOptions`. + /// + /// Sets the minimum count of each character type to `0` and does not exclude + /// any characters. + fn default() -> Self { + PwdGenOptions::default_() + } +} + +struct CharacterSet { + upper: Vec, + lower: Vec, + digit: Vec, + special: Vec, +} + +/// Password generator struct. +pub struct PwdGen<'a> { + length: usize, + options: PwdGenOptions<'a>, + // TODO?: charset same as union of upper, lower, digit, special. + charset: Vec, + upper: Vec, + lower: Vec, + digit: Vec, + special: Vec, +} + +impl<'a> PwdGen<'a> { + /// Creates a new password generator. + /// + /// # Parameters + /// + /// - `length`: The desired length of generated passwords. Must be at least + /// `MIN_LENGTH`. Default is `MIN_LENGTH`. + /// - `options`: Optional `PwdGenOptions` specifying constraints for password + /// generation, such as minimum numbers of different character types and + /// characters to exclude. If `None` is provided, default options are used. + /// + /// # Returns + /// + /// Returns a `Result`, where `PwdGen` is the initialized + /// password generator if no errors are encountered. + pub fn new( + length: usize, + options: Option>, + ) -> Result { + let options = options.unwrap_or_default(); + + let cset = Self::validate_input(length, &options)?; + + let charset = [ + &cset.upper[..], + &cset.lower[..], + &cset.digit[..], + &cset.special[..], + ] + .concat(); + + Ok(PwdGen { + length, + options, + charset, + upper: cset.upper, + lower: cset.lower, + digit: cset.digit, + special: cset.special, + }) + } + + /// Generates a random password, respecting the constraints specified in the + /// constructor. + pub fn gen(&self) -> String { + let mut chars: Vec = Vec::with_capacity(self.length); + + Self::add_random_chars(&mut chars, &self.upper, self.options.min_upper); + Self::add_random_chars(&mut chars, &self.lower, self.options.min_lower); + Self::add_random_chars(&mut chars, &self.digit, self.options.min_digit); + Self::add_random_chars(&mut chars, &self.special, self.options.min_special); + + chars.extend( + std::iter::repeat_with(|| { + *self + .charset + .choose(&mut OsRng) + .expect("Filtered charset is nonempty") + }) + .take(self.length - chars.len()), + ); + + chars.shuffle(&mut OsRng); + + chars.into_iter().collect() + } + + fn add_random_chars(chars: &mut Vec, range: &[char], count: usize) { + chars.extend((0..count).filter_map(|_| range.choose(&mut OsRng))); + } + + fn validate_input( + length: usize, + options: &PwdGenOptions, + ) -> Result { + if length < MIN_LENGTH { + return Err(Error::Length); + } + + let min_total = checked_sum( + [ + options.min_upper, + options.min_lower, + options.min_digit, + options.min_special, + ] + .iter() + .cloned(), + ); + if min_total.is_none() || min_total.unwrap() > length { + return Err(Error::MinLimitExceeded); + } + + let exclude: Option> = + Some(options.exclude.unwrap_or("").chars().collect()); + + let upper = filtered_range('A'..='Z', &exclude); + if upper.len() < options.min_upper { + return Err(Error::InsufficientCharacters("upper")); + } + let lower = filtered_range('a'..='z', &exclude); + if lower.len() < options.min_lower { + return Err(Error::InsufficientCharacters("lower")); + } + let digit = filtered_range('0'..='9', &exclude); + if digit.len() < options.min_digit { + return Err(Error::InsufficientCharacters("digit")); + } + let special = filtered_range(SPECIAL_CHARS.iter().cloned(), &exclude); + if special.len() < options.min_special { + return Err(Error::InsufficientCharacters("special")); + } + + Ok(CharacterSet { + upper, + lower, + digit, + special, + }) + } + + pub fn length(&self) -> usize { + self.length + } + + pub fn options(&self) -> &PwdGenOptions { + &self.options + } +} + +pub fn gen( + length: usize, + options: Option, +) -> Result { + let pwdgen = PwdGen::new(length, options)?; + Ok(pwdgen.gen()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_password_length() { + let length = 10; + let pwdgen = PwdGen::new(length, None).unwrap(); + let password = pwdgen.gen(); + assert_eq!(password.len(), length); + } + + #[test] + fn test_minimum_length_password() { + let pwdgen = PwdGen::new(MIN_LENGTH, None).unwrap(); + let password = pwdgen.gen(); + assert_eq!(password.len(), MIN_LENGTH); + } + + #[test] + fn test_long_password() { + let length = 100; + let pwdgen = PwdGen::new(length, None).unwrap(); + let password = pwdgen.gen(); + assert_eq!(password.len(), length); + } + + #[test] + fn test_error_on_short_password() { + let pwdgen = PwdGen::new(7, None); + assert!(matches!(pwdgen, Err(Error::Length))); + } + + #[test] + fn test_sum_of_minimums_exceeds_length_error() { + let options = PwdGenOptions { + min_upper: 3, + min_lower: 3, + min_digit: 3, + min_special: 3, + exclude: None, + }; + let pwdgen = PwdGen::new(10, Some(options)); + assert!(matches!(pwdgen, Err(Error::MinLimitExceeded))); + } + + #[test] + fn test_password_with_custom_options() { + let options = PwdGenOptions { + min_upper: 3, + min_lower: 3, + min_digit: 3, + min_special: 3, + exclude: None, + }; + + let pwdgen = PwdGen::new(15, Some(options)).unwrap(); + let password = pwdgen.gen(); + + assert!(password.chars().filter(|c| c.is_uppercase()).count() >= 3); + assert!(password.chars().filter(|c| c.is_lowercase()).count() >= 3); + assert!(password.chars().filter(|c| c.is_digit(10)).count() >= 3); + assert!( + password + .chars() + .filter(|c| SPECIAL_CHARS.contains(&c)) + .count() + >= 3 + ); + } + + #[test] + fn test_password_excluding_characters() { + let exclude = "Aa1@"; + let options = PwdGenOptions { + min_upper: 2, + min_lower: 2, + min_digit: 2, + min_special: 2, + exclude: Some(exclude), + }; + + let pwdgen = PwdGen::new(12, Some(options)).unwrap(); + let password = pwdgen.gen(); + + assert!(!password.contains('A')); + assert!(!password.contains('a')); + assert!(!password.contains('1')); + assert!(!password.contains('@')); + } + + #[test] + fn test_exact_sum_of_minimums_equals_length() { + let length = 12; + let min_count = 3; + let options = PwdGenOptions { + min_upper: min_count, + min_lower: min_count, + min_digit: min_count, + min_special: min_count, + exclude: None, + }; + + let pwdgen = PwdGen::new(length, Some(options)).unwrap(); + let password = pwdgen.gen(); + + assert_eq!(password.len(), 12); + + assert_eq!( + password.chars().filter(|c| c.is_uppercase()).count(), + min_count + ); + assert_eq!( + password.chars().filter(|c| c.is_lowercase()).count(), + min_count + ); + assert_eq!( + password.chars().filter(|c| c.is_digit(10)).count(), + min_count + ); + assert_eq!( + password + .chars() + .filter(|c| SPECIAL_CHARS.contains(&c)) + .count(), + min_count + ); + } + + #[test] + fn validate_input_short_length() { + let options = PwdGenOptions::default(); + assert!(matches!( + PwdGen::validate_input(7, &options), + Err(Error::Length) + )); + } + + #[test] + fn validate_input_min_sum_exceeds_length() { + let options = PwdGenOptions { + min_upper: 3, + min_lower: 3, + min_digit: 3, + min_special: 3, + ..Default::default() + }; + assert!(matches!( + PwdGen::validate_input(10, &options), + Err(Error::MinLimitExceeded) + )); + } + + #[test] + fn validate_input_insufficient_upper_chars() { + let exclude: String = ('A'..='Z').collect(); + let options = PwdGenOptions { + min_upper: 1, + exclude: Some(&exclude), + ..Default::default() + }; + assert!(matches!( + PwdGen::validate_input(10, &options), + Err(Error::InsufficientCharacters("upper")) + )); + } + + #[test] + fn validate_input_insufficient_lower_chars() { + let exclude: String = ('a'..='z').collect(); + let options = PwdGenOptions { + min_lower: 1, + exclude: Some(&exclude), + ..Default::default() + }; + assert!(matches!( + PwdGen::validate_input(10, &options), + Err(Error::InsufficientCharacters("lower")) + )); + } + + #[test] + fn validate_input_insufficient_digit_chars() { + let exclude: String = ('0'..='9').collect(); + let options = PwdGenOptions { + min_digit: 1, + exclude: Some(&exclude), + ..Default::default() + }; + assert!(matches!( + PwdGen::validate_input(10, &options), + Err(Error::InsufficientCharacters("digit")) + )); + } + + #[test] + fn validate_input_insufficient_special_chars() { + let exclude_special: String = SPECIAL_CHARS.iter().collect(); + let options = PwdGenOptions { + min_special: 1, + exclude: Some(&exclude_special), + ..Default::default() + }; + assert!(matches!( + PwdGen::validate_input(10, &options), + Err(Error::InsufficientCharacters("special")) + )); + } + + #[test] + fn test_get_length() { + let length = 23; + let pwdgen = PwdGen::new(length, None).unwrap(); + assert_eq!(length, pwdgen.length()); + } + + #[test] + fn test_get_options() { + let exclude: String = SPECIAL_CHARS.iter().collect(); + let length = 23; + let options = PwdGenOptions { + min_upper: 1, + min_lower: 2, + min_digit: 3, + min_special: 0, + exclude: Some(&exclude), + }; + let options_clone = options.clone(); + let pwdgen = PwdGen::new(length, Some(options)).unwrap(); + + assert_eq!(options_clone, *pwdgen.options()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..b43cb03 --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,15 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +#![doc = include_str!("../README.md")] +mod charset; +mod error; +mod generator; +mod util; + +pub use charset::SPECIAL_CHARS; +pub use error::Error; +pub use generator::{ + gen, PwdGen, PwdGenOptions, DEFAULT_PWDGEN_OPTIONS, MIN_LENGTH, +}; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..700b77a --- /dev/null +++ b/src/main.rs @@ -0,0 +1,82 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +use clap::Parser; +use pwdg::DEFAULT_PWDGEN_OPTIONS as DEF; + +#[derive(Parser)] +#[clap(about, version, author)] +struct Cli { + /// Sets the length of the password. Must be at least 8. + #[clap(short, long, default_value_t = pwdg::MIN_LENGTH)] + length: usize, + + /// Minimum number of uppercase characters (A to Z). + #[clap(long, default_value_t = DEF.min_upper)] + min_upper: usize, + + /// Minimum number of lowercase characters (a to z). + #[clap(long, default_value_t = DEF.min_lower)] + min_lower: usize, + + /// Minimum number of digit characters (0 to 9). + #[clap(long, default_value_t = DEF.min_digit)] + min_digit: usize, + + /// Minimum number of special characters. + #[clap(long, default_value_t = DEF.min_special, help = &format!( + "Minimum number of special characters.\nSpecial characters: {}", + pwdg::SPECIAL_CHARS.iter().collect::() + ))] + min_special: usize, + + /// Characters to exclude from the overall character set used for password + /// generation. + #[clap(short, long)] + exclude: Option, + + /// Generates a password with at least 1 uppercase letter, 1 lowercase letter, + /// 1 digit, and 1 special character. This option overrides --min-upper, + /// --min-lower, --min-digit, and --min-special if they are also set. + #[clap(short, long, action = clap::ArgAction::SetTrue)] + strong: bool, +} + +fn main() { + let cli = Cli::parse(); + + if let Err(e) = run(cli) { + eprintln!("{}", e); + std::process::exit(1); + } +} + +fn run(cli: Cli) -> Result<(), pwdg::Error> { + let options = get_options(&cli)?; + let password = pwdg::gen(cli.length, Some(options))?; + + println!("{}", password); + + Ok(()) +} + +fn get_options(cli: &Cli) -> Result { + let mut options = pwdg::PwdGenOptions::default(); + + if cli.strong { + options.min_upper = 1; + options.min_lower = 1; + options.min_digit = 1; + options.min_special = 1; + } else { + options.min_upper = cli.min_upper; + options.min_lower = cli.min_lower; + options.min_digit = cli.min_digit; + options.min_special = cli.min_special; + } + + options.exclude = cli.exclude.as_deref(); + + Ok(options) +} diff --git a/src/util.rs b/src/util.rs new file mode 100644 index 0000000..6f9fbfe --- /dev/null +++ b/src/util.rs @@ -0,0 +1,9 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +mod filter; +mod uint; + +pub use filter::filtered_range; +pub use uint::checked_sum; diff --git a/src/util/filter.rs b/src/util/filter.rs new file mode 100644 index 0000000..76a7bfd --- /dev/null +++ b/src/util/filter.rs @@ -0,0 +1,63 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +use std::cmp::Eq; +use std::collections::HashSet; +use std::hash::Hash; + +pub fn filtered_range( + range: impl Iterator, + exclude: &Option>, +) -> Vec +where + T: Eq + Hash, +{ + match exclude { + Some(exclusions) => range.filter(|c| !exclusions.contains(c)).collect(), + None => range.collect(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_filtered_range_no_exclusions() { + let range = 'a'..='c'; + let result: Vec = filtered_range(range, &None); + assert_eq!(result, vec!['a', 'b', 'c']); + } + + #[test] + fn test_filtered_range_with_exclusions() { + let range = 'a'..='c'; + let exclusions: HashSet = ['b'].iter().cloned().collect(); + let result: Vec = filtered_range(range, &Some(exclusions)); + assert_eq!(result, vec!['a', 'c']); + } + + #[test] + fn test_filtered_range_empty_range() { + let range = 'a'..'a'; + let result: Vec = filtered_range(range, &None); + assert!(result.is_empty()); + } + + #[test] + fn test_filtered_range_full_exclusions() { + let range = 'a'..='c'; + let exclusions: HashSet = ['a', 'b', 'c'].iter().cloned().collect(); + let result: Vec = filtered_range(range, &Some(exclusions)); + assert!(result.is_empty()); + } + + #[test] + fn test_filtered_range_non_overlapping_exclusions() { + let range = 'a'..='c'; + let exclusions: HashSet = ['x', 'y', 'z'].iter().cloned().collect(); + let result: Vec = filtered_range(range, &Some(exclusions)); + assert_eq!(result, vec!['a', 'b', 'c']); + } +} diff --git a/src/util/uint.rs b/src/util/uint.rs new file mode 100644 index 0000000..b1382e6 --- /dev/null +++ b/src/util/uint.rs @@ -0,0 +1,63 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +pub trait CheckedAdd: Sized { + fn checked_add(&self, other: Self) -> Option; +} + +pub fn checked_sum(iter: I) -> Option +where + I: Iterator, + T: CheckedAdd + Default, +{ + let mut acc = T::default(); + for item in iter { + acc = match acc.checked_add(item) { + Some(sum) => sum, + None => return None, + }; + } + Some(acc) +} + +macro_rules! impl_checked_add { + ($($t:ty),*) => { + $(impl CheckedAdd for $t { + fn checked_add(&self, other: Self) -> Option { + <$t>::checked_add(*self, other) + } + })* + } +} + +impl_checked_add!(u8, u16, u32, u64, u128, usize); + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_checked_sum_success() { + assert_eq!(checked_sum([1 as u8, 2, 3].iter().cloned()), Some(6)); + assert_eq!( + checked_sum([100 as u16, 200, 300].iter().cloned()), + Some(600) + ); + assert_eq!( + checked_sum([u32::MAX - 200, 100, 100].iter().cloned()), + Some(u32::MAX) + ); + } + + #[test] + fn test_checked_sum_overflow() { + assert_eq!(checked_sum([usize::MAX, 1].iter().cloned()), None); + assert_eq!(checked_sum([u32::MAX, 1].iter().cloned()), None); + assert_eq!(checked_sum([u32::MAX, u32::MAX, 578].iter().cloned()), None); + assert_eq!( + checked_sum([u32::MAX - 60000, 30000, 30001].iter().cloned()), + None + ); + } +} diff --git a/tests/cli_test.rs b/tests/cli_test.rs new file mode 100644 index 0000000..d07c9f4 --- /dev/null +++ b/tests/cli_test.rs @@ -0,0 +1,236 @@ +/* +Copyright 2024 Owain Davies +SPDX-License-Identifier: Apache-2.0 +*/ +use pwdg::SPECIAL_CHARS; +use std::process::Command; + +fn run_app(args: &[&str]) -> Result { + let path = if cfg!(debug_assertions) { + "./target/debug/pwdg" + } else { + "./target/release/pwdg" + }; + + let output = Command::new(path) + .args(args) + .output() + .expect("failed to execute process"); + + if output.status.success() { + Ok( + String::from_utf8(output.stdout) + .unwrap_or_else(|_| "Non-UTF-8 output from command".to_string()), + ) + } else { + Err( + String::from_utf8(output.stderr) + .unwrap_or_else(|_| "Non-UTF-8 error output from command".to_string()), + ) + } +} + +#[test] +fn test_password_default_length() { + if let Ok(output) = run_app(&[]) { + assert_eq!(output.trim().len(), 8); + } else { + panic!("Default length password generation should succeed."); + } +} + +#[test] +fn test_password_custom_length() { + if let Ok(output) = run_app(&["-l", "12"]) { + assert_eq!(output.trim().len(), 12); + } else { + panic!("Custom length password generation with '-l' flag should succeed."); + } + + if let Ok(output) = run_app(&["--length=12"]) { + assert_eq!(output.trim().len(), 12); + } else { + panic!( + "Custom length password generation with '--length' flag should succeed." + ); + } +} + +#[test] +fn test_reject_short_password() { + const ERR_MESSAGE: &str = "Short password length should be rejected."; + + if let Err(err) = run_app(&["-l", "6"]) { + assert!(err.contains("Password length must be at least 8 characters.")); + } else { + panic!("{}", ERR_MESSAGE); + } + + if let Err(err) = run_app(&["--length=6"]) { + assert!(err.contains("Password length must be at least 8 characters.")); + } else { + panic!("{}", ERR_MESSAGE); + } +} + +fn count_chars(input: &str, f: F) -> usize +where + F: Fn(&char) -> bool, +{ + input.chars().filter(f).count() +} + +#[test] +fn test_minimum_character_types() { + if let Ok(output) = run_app(&[ + "-l", + "12", + "--min-upper=2", + "--min-lower=2", + "--min-digit=2", + "--min-special=2", + ]) { + let password = output.trim(); + assert!(count_chars(&password, |c| c.is_uppercase()) >= 2); + assert!(count_chars(&password, |c| c.is_lowercase()) >= 2); + assert!(count_chars(&password, |c| c.is_digit(10)) >= 2); + assert!(count_chars(&password, |c| SPECIAL_CHARS.contains(c)) >= 2); + } else { + panic!("Password should contain at least 2 characters from each category."); + } +} + +#[test] +fn test_excluded_characters() { + if let Ok(output) = run_app(&["--exclude=ABCDE12345"]) { + let password = output.trim(); + assert!(!password.contains('A')); + assert!(!password.contains('B')); + assert!(!password.contains('C')); + assert!(!password.contains('D')); + assert!(!password.contains('E')); + assert!(!password.contains('1')); + assert!(!password.contains('2')); + assert!(!password.contains('3')); + assert!(!password.contains('4')); + assert!(!password.contains('5')); + } else { + panic!( + "Exclusion of specified characters in password generation should succeed." + ); + } +} + +fn test_exclusion_logic(exclude_chars: &str, expected_chars: &[char]) { + if let Ok(output) = run_app(&["--exclude", exclude_chars]) { + let password = output.trim(); + for &char in expected_chars { + assert!( + !password.contains(char), + "Password should not contain the excluded character '{}'", + char + ); + } + } else { + panic!( + "Exclusion of characters '{}' should succeed.", + exclude_chars + ); + } +} + +#[test] +fn test_exclusion_of_single_character() { + test_exclusion_logic("Z", &['Z']); +} + +#[test] +fn test_exclusion_of_some_special_characters() { + let exclude_chars = "#$%&"; + test_exclusion_logic( + exclude_chars, + &exclude_chars.chars().collect::>(), + ); +} + +#[test] +fn test_exclusion_of_all_special_characters() { + let exclude_chars = SPECIAL_CHARS.iter().collect::(); + test_exclusion_logic(&exclude_chars, SPECIAL_CHARS); +} + +#[test] +fn test_help_option() { + if let Ok(output) = run_app(&["--help"]) { + assert!(output.contains("Usage: pwdg [OPTIONS]")); + } else { + panic!("Displaying help information should succeed."); + } +} + +#[test] +fn test_version_option() { + if let Ok(output) = run_app(&["--version"]) { + assert!(output.contains(concat!("pwdg ", env!("CARGO_PKG_VERSION")))) + } else { + panic!("Displaying version information should succeed."); + } +} + +#[test] +fn test_invalid_argument() { + if let Err(err) = run_app(&["--invalid"]) { + assert!(err.contains("error: unexpected argument")); + } else { + panic!("Invalid argument handling should produce an error."); + } +} + +#[test] +fn test_strong_password_option() { + if let Ok(output) = run_app(&["--strong"]) { + let password = output.trim(); + + assert!( + password.chars().any(|c| c.is_uppercase()), + "Password must contain at least one uppercase letter." + ); + assert!( + password.chars().any(|c| c.is_lowercase()), + "Password must contain at least one lowercase letter." + ); + assert!( + password.chars().any(|c| c.is_digit(10)), + "Password must contain at least one digit." + ); + assert!( + password.chars().any(|c| SPECIAL_CHARS.contains(&c)), + "Password must contain at least one special character." + ); + } else { + panic!("Strong password generation should succeed."); + } +} + +#[test] +fn test_combined_options_length_and_exclusion() { + let exclude_chars = "ABCD"; + let length = "15"; + + if let Ok(output) = run_app(&["-l", length, "--exclude", exclude_chars]) { + let password = output.trim(); + assert_eq!(password.len(), 15, "Password length should be 15."); + for char in exclude_chars.chars() { + assert!( + !password.contains(char), + "Password should not contain the excluded character '{}'", + char + ); + } + } else { + panic!(concat!( + "Password generation with combined length and exclusion options should", + " succeed." + )); + } +}