├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yml │ ├── documentation-improvement.md │ ├── feature-request.md │ └── question.md ├── pull_request_template.md └── workflows │ ├── all-tests-slow.yml │ ├── build-artifacts-and-run-tests.yml │ ├── draft-release-automatic-trigger.yml │ └── pr-workflow.yml ├── .gitignore ├── CHANGELOG.md ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── README.md ├── benchmarks ├── results.md ├── run-benchmarks.sh ├── setup-benchmarks.sh └── shell.nix ├── build.rs ├── rustfmt.toml ├── scripts └── package-release-assets.sh ├── src ├── accessible.rs ├── archive │ ├── bzip3_stub.rs │ ├── mod.rs │ ├── rar.rs │ ├── rar_stub.rs │ ├── sevenz.rs │ ├── tar.rs │ └── zip.rs ├── check.rs ├── cli │ ├── args.rs │ └── mod.rs ├── commands │ ├── compress.rs │ ├── decompress.rs │ ├── list.rs │ └── mod.rs ├── error.rs ├── extension.rs ├── list.rs ├── main.rs └── utils │ ├── colors.rs │ ├── file_visibility.rs │ ├── formatting.rs │ ├── fs.rs │ ├── io.rs │ ├── logger.rs │ ├── mod.rs │ └── question.rs └── tests ├── data ├── testfile.rar3.rar.gz └── testfile.rar5.rar ├── integration.rs ├── mime.rs ├── snapshots ├── ui__ui_test_err_compress_missing_extension.snap ├── ui__ui_test_err_decompress_missing_extension_with_rar-1.snap ├── ui__ui_test_err_decompress_missing_extension_with_rar-2.snap ├── ui__ui_test_err_decompress_missing_extension_with_rar-3.snap ├── ui__ui_test_err_decompress_missing_extension_without_rar-1.snap ├── ui__ui_test_err_decompress_missing_extension_without_rar-2.snap ├── ui__ui_test_err_decompress_missing_extension_without_rar-3.snap ├── ui__ui_test_err_format_flag_with_rar-1.snap ├── ui__ui_test_err_format_flag_with_rar-2.snap ├── ui__ui_test_err_format_flag_with_rar-3.snap ├── ui__ui_test_err_format_flag_without_rar-1.snap ├── ui__ui_test_err_format_flag_without_rar-2.snap ├── ui__ui_test_err_format_flag_without_rar-3.snap ├── ui__ui_test_err_missing_files-2.snap ├── ui__ui_test_err_missing_files-3.snap ├── ui__ui_test_err_missing_files.snap ├── ui__ui_test_ok_compress-2.snap ├── ui__ui_test_ok_compress.snap ├── ui__ui_test_ok_decompress.snap ├── ui__ui_test_ok_decompress_multiple_files.snap ├── ui__ui_test_ok_format_flag_with_rar-1.snap ├── ui__ui_test_ok_format_flag_with_rar-2.snap ├── ui__ui_test_ok_format_flag_without_rar-1.snap ├── ui__ui_test_ok_format_flag_without_rar-2.snap ├── ui__ui_test_usage_help_flag-2.snap └── ui__ui_test_usage_help_flag.snap ├── ui.rs └── utils.rs /.github/ISSUE_TEMPLATE/bug-report.yml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: Report a ouch bug 3 | labels: [bug] 4 | body: 5 | - type: markdown 6 | attributes: 7 | value: Thanks for filling a bug report! 8 | 9 | - type: input 10 | id: version 11 | attributes: 12 | label: Version 13 | description: Version of ouch you encountered this error, or git commit hash if you are not using a versioned release 14 | validations: 15 | required: true 16 | 17 | - type: textarea 18 | id: description 19 | attributes: 20 | label: Description 21 | validations: 22 | required: true 23 | 24 | - type: textarea 25 | id: current 26 | attributes: 27 | label: Current Behavior 28 | 29 | - type: textarea 30 | id: expected 31 | attributes: 32 | label: Expected Behavior 33 | 34 | - type: textarea 35 | id: info 36 | attributes: 37 | label: Additional Information 38 | description: > 39 | Additional information that might help us debug this issue, 40 | such as the operating system you are using or the files you tried to archive/extract 41 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/documentation-improvement.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Documentation Improvement 3 | about: Improvements in repository markdown or code documentations 4 | labels: documentation 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Feature / Enhancement 3 | about: Features or change you want to see in ouch 4 | labels: enhancement 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/question.md: -------------------------------------------------------------------------------- 1 | --- 2 | name: Question 3 | about: "Ask a question about ouch. You can also ask questions in discussions: https://github.com/ouch-org/ouch/discussions" 4 | labels: question 5 | 6 | --- 7 | -------------------------------------------------------------------------------- /.github/pull_request_template.md: -------------------------------------------------------------------------------- 1 | 7 | -------------------------------------------------------------------------------- /.github/workflows/all-tests-slow.yml: -------------------------------------------------------------------------------- 1 | name: Run tests for all combinations 2 | 3 | on: 4 | schedule: 5 | - cron: "0 0 1,15 * *" # biweekly 6 | push: 7 | branches: 8 | - main 9 | paths-ignore: 10 | - "**/*.md" 11 | 12 | jobs: 13 | run-tests-for-all-combinations: 14 | uses: ./.github/workflows/build-artifacts-and-run-tests.yml 15 | with: 16 | matrix_all_combinations: true 17 | artifact_upload_mode: none 18 | -------------------------------------------------------------------------------- /.github/workflows/build-artifacts-and-run-tests.yml: -------------------------------------------------------------------------------- 1 | # This is a reusable workflow 2 | 3 | name: Build artifacts and run tests 4 | 5 | on: 6 | workflow_dispatch: 7 | inputs: 8 | matrix_all_combinations: 9 | description: "if matrix should have all combinations of targets and features" 10 | type: boolean 11 | required: true 12 | default: true 13 | artifact_upload_mode: 14 | description: "Control what artifacts to upload: 'none' for no uploads, 'with_default_features' to upload artifacts with default features (for releases), or 'all' for all feature combinations." 15 | type: choice 16 | options: 17 | - none 18 | - with_default_features 19 | - all 20 | required: true 21 | workflow_call: 22 | inputs: 23 | matrix_all_combinations: 24 | description: "if matrix should have all combinations of targets and features" 25 | type: boolean 26 | required: true 27 | artifact_upload_mode: 28 | description: "Control which artifacts to upload: 'none' for no uploads, 'with_default_features' to upload only artifacts with default features (use_zlib+use_zstd_thin+unrar+bzip3), or 'all' to upload all feature combinations." 29 | type: string 30 | required: true 31 | 32 | jobs: 33 | build-artifacts-and-run-tests: 34 | runs-on: ${{ matrix.os || 'ubuntu-latest' }} 35 | env: 36 | CARGO: cargo 37 | strategy: 38 | fail-fast: false 39 | matrix: 40 | # TODO: avoid exploding the matrix by removing unrar and bzip3 from the all combinations runs 41 | # I can add a monthly run with all combinations 42 | feature-unrar: ${{ inputs.matrix_all_combinations && fromJSON('[true, false]') || fromJSON('[true]')}} 43 | feature-bzip3: ${{ inputs.matrix_all_combinations && fromJSON('[true, false]') || fromJSON('[true]')}} 44 | feature-use-zlib: ${{ inputs.matrix_all_combinations && fromJSON('[true, false]') || fromJSON('[false]')}} 45 | feature-use-zstd-thin: ${{ inputs.matrix_all_combinations && fromJSON('[true, false]') || fromJSON('[false]')}} 46 | target: 47 | # native 48 | - x86_64-unknown-linux-gnu 49 | - x86_64-pc-windows-gnu 50 | - x86_64-pc-windows-msvc 51 | - aarch64-pc-windows-msvc 52 | - x86_64-apple-darwin 53 | # cross 54 | - x86_64-unknown-linux-musl 55 | - aarch64-unknown-linux-gnu 56 | - aarch64-unknown-linux-musl 57 | - armv7-unknown-linux-gnueabihf 58 | - armv7-unknown-linux-musleabihf 59 | 60 | include: 61 | # runner overrides 62 | - target: x86_64-pc-windows-gnu 63 | os: windows-latest 64 | - target: x86_64-pc-windows-msvc 65 | os: windows-latest 66 | - target: aarch64-pc-windows-msvc 67 | os: windows-latest 68 | - target: x86_64-apple-darwin 69 | os: macos-latest 70 | # targets that use cross 71 | - target: x86_64-unknown-linux-musl 72 | use-cross: true 73 | - target: aarch64-unknown-linux-gnu 74 | use-cross: true 75 | - target: aarch64-unknown-linux-musl 76 | use-cross: true 77 | - target: armv7-unknown-linux-gnueabihf 78 | use-cross: true 79 | - target: armv7-unknown-linux-musleabihf 80 | use-cross: true 81 | # features (unless `matrix_all_combinations` is true, we only run these on linux-gnu) 82 | - feature-unrar: false 83 | target: x86_64-unknown-linux-gnu 84 | - feature-use-zlib: true 85 | target: x86_64-unknown-linux-gnu 86 | - feature-use-zstd-thin: true 87 | target: x86_64-unknown-linux-gnu 88 | - feature-bzip3: false 89 | target: x86_64-unknown-linux-gnu 90 | 91 | steps: 92 | - name: Checkout 93 | uses: actions/checkout@v4 94 | 95 | - name: Install cross 96 | if: matrix.use-cross 97 | run: | 98 | pushd "$(mktemp -d)" 99 | wget https://github.com/cross-rs/cross/releases/download/v0.2.4/cross-x86_64-unknown-linux-musl.tar.gz 100 | tar xf cross-x86_64-unknown-linux-musl.tar.gz 101 | cp cross ~/.cargo/bin 102 | popd 103 | echo CARGO=cross >> $GITHUB_ENV 104 | 105 | - name: Concatenate features 106 | id: concat-features 107 | shell: bash 108 | run: | 109 | FEATURES=(allow_piped_choice) 110 | if [[ "${{ matrix.feature-unrar }}" == true ]]; then FEATURES+=(unrar); fi 111 | if [[ "${{ matrix.feature-use-zlib }}" == true ]]; then FEATURES+=(use_zlib); fi 112 | if [[ "${{ matrix.feature-use-zstd-thin }}" == true ]]; then FEATURES+=(use_zstd_thin); fi 113 | if [[ "${{ matrix.feature-bzip3 }}" == true ]]; then FEATURES+=(bzip3); fi 114 | # Output plus-separated list for artifact names 115 | IFS='+' 116 | echo "FEATURES_PLUS=${FEATURES[*]}" >> $GITHUB_OUTPUT 117 | # Output comma-separated list for cargo flags 118 | IFS=',' 119 | echo "FEATURES_COMMA=${FEATURES[*]}" >> $GITHUB_OUTPUT 120 | 121 | - name: Set up extra cargo flags 122 | env: 123 | FEATURES: ${{steps.concat-features.outputs.FEATURES_COMMA}} 124 | shell: bash 125 | run: | 126 | FLAGS="--no-default-features" 127 | if [[ -n "$FEATURES" ]]; then FLAGS+=" --features $FEATURES"; fi 128 | echo "EXTRA_CARGO_FLAGS=$FLAGS" >> $GITHUB_ENV 129 | 130 | - name: Install Rust 131 | run: | 132 | rustup toolchain install stable --profile minimal -t ${{ matrix.target }} 133 | 134 | - uses: Swatinem/rust-cache@v2 135 | with: 136 | key: "${{ matrix.target }}-${{ matrix.feature-unrar }}-${{ matrix.feature-use-zlib }}-${{ matrix.feature-use-zstd-thin }}-${{ matrix.feature-bzip3 }}" 137 | 138 | - name: Test on stable 139 | # there's no way to run tests for ARM64 Windows for now 140 | if: matrix.target != 'aarch64-pc-windows-msvc' 141 | run: | 142 | ${{ env.CARGO }} +stable test --profile fast --target ${{ matrix.target }} $EXTRA_CARGO_FLAGS 143 | 144 | - name: Build release artifacts (binary and completions) 145 | if: ${{ inputs.artifact_upload_mode != 'none' }} 146 | run: | 147 | ${{ env.CARGO }} +stable build --release --target ${{ matrix.target }} $EXTRA_CARGO_FLAGS 148 | env: 149 | OUCH_ARTIFACTS_FOLDER: man-page-and-completions-artifacts 150 | 151 | - name: Upload release artifacts 152 | if: | 153 | ${{ inputs.artifact_upload_mode != 'none' && 154 | (inputs.artifact_upload_mode == 'all' || 155 | (matrix.feature-unrar && matrix.feature-use-zlib && matrix.feature-use-zstd-thin && matrix.feature-bzip3)) }} 156 | uses: actions/upload-artifact@v4 157 | with: 158 | name: ouch-${{ matrix.target }}${{ steps.concat-features.outputs.FEATURES_PLUS != '' && format('-{0}', steps.concat-features.outputs.FEATURES_PLUS) || '' }} 159 | path: | 160 | target/${{ matrix.target }}/release/ouch 161 | target/${{ matrix.target }}/release/ouch.exe 162 | man-page-and-completions-artifacts/ 163 | -------------------------------------------------------------------------------- /.github/workflows/draft-release-automatic-trigger.yml: -------------------------------------------------------------------------------- 1 | name: Automatic trigger draft release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "[0-9]+.[0-9]+.[0-9]+-rc[0-9]+" 7 | 8 | jobs: 9 | call-workflow-build-artifacts-and-run-tests: 10 | uses: ./.github/workflows/build-artifacts-and-run-tests.yml 11 | with: 12 | matrix_all_combinations: true 13 | artifact_upload_mode: with_default_features 14 | 15 | automated-draft-release: 16 | runs-on: ubuntu-latest 17 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/') 18 | needs: call-workflow-build-artifacts-and-run-tests 19 | steps: 20 | - name: Checkout 21 | uses: actions/checkout@v4 22 | 23 | - name: Download artifacts 24 | uses: actions/download-artifact@v4 25 | with: 26 | path: downloaded_artifacts 27 | pattern: ouch-* 28 | 29 | - name: Package release assets 30 | run: scripts/package-release-assets.sh 31 | 32 | - name: Create release 33 | uses: softprops/action-gh-release@v2 34 | with: 35 | draft: true 36 | files: output_assets/ouch-* 37 | -------------------------------------------------------------------------------- /.github/workflows/pr-workflow.yml: -------------------------------------------------------------------------------- 1 | name: PR workflow 2 | 3 | on: 4 | pull_request: 5 | paths-ignore: 6 | - "**/*.md" 7 | 8 | jobs: 9 | rustfmt-nightly-check: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - name: Checkout 13 | uses: actions/checkout@v4 14 | 15 | - name: "Cargo: fmt" 16 | run: | 17 | rustup toolchain install nightly --profile minimal -c rustfmt 18 | cargo +nightly fmt -- --check 19 | 20 | clippy-checks: 21 | runs-on: ubuntu-latest 22 | steps: 23 | - name: Checkout 24 | uses: actions/checkout@v4 25 | 26 | - name: "Cargo: clippy" 27 | run: | 28 | rustup toolchain install stable --profile minimal -c clippy 29 | cargo +stable clippy -- -D warnings 30 | 31 | build-and-test: 32 | uses: ./.github/workflows/build-artifacts-and-run-tests.yml 33 | with: 34 | matrix_all_combinations: false 35 | artifact_upload_mode: none 36 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Cargo artifacts 2 | target/ 3 | 4 | # These are backup files generated by rustfmt 5 | **/*.rs.bk 6 | 7 | # crash logs generated by proptest 8 | *.proptest-regressions 9 | 10 | # Common folder for generated shell completions and man pages 11 | artifacts/ 12 | 13 | # extra files generated by benchmarks 14 | /benchmarks/compiler/ 15 | /benchmarks/rust/ 16 | /benchmarks/input.* 17 | /benchmarks/*.md 18 | !/benchmarks/results.md 19 | 20 | # IDE-specific setting 21 | .vscode 22 | .idea 23 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | Thanks for your interest in contributing to `ouch`! 2 | 3 | # Table of contents: 4 | 5 | - [Code of Conduct](#code-of-conduct) 6 | - [I want to ask a question or provide feedback](#i-want-to-ask-a-question-or-provide-feedback) 7 | - [Adding a new feature](#adding-a-new-feature) 8 | - [PRs](#prs) 9 | - [Dealing with UI tests](#dealing-with-ui-tests) 10 | 11 | ## Code of Conduct 12 | 13 | We follow the [Rust Official Code of Conduct](https://www.rust-lang.org/policies/code-of-conduct). 14 | 15 | ## I want to ask a question or provide feedback 16 | 17 | Create [an issue](https://github.com/ouch-org/ouch/issues) or go to [Ouch Discussions](https://github.com/ouch-org/ouch/discussions). 18 | 19 | ## Adding a new feature 20 | 21 | Before opening the PR, open an issue to discuss your addition, this increases the chance of your PR being accepted. 22 | 23 | ## PRs 24 | 25 | - Pass all CI checks. 26 | - After opening the PR, add a [CHANGELOG.md] entry. 27 | 28 | [CHANGELOG.md]: https://github.com/ouch-org/ouch 29 | 30 | ## Dealing with UI tests 31 | 32 | We use snapshots to do UI testing and guarantee a consistent output, this way, you can catch accidental changes or see what output changed in the PR diff. 33 | 34 | - Run tests with `cargo` normally, or with a filter: 35 | 36 | ```sh 37 | cargo test 38 | # Only run UI tests 39 | cargo test -- ui 40 | ``` 41 | 42 | - If some UI test failed, you should review it: 43 | 44 | ```sh 45 | cargo insta review 46 | ``` 47 | 48 | - After addressing all, you should be able to `git add` and `commit` accordingly. 49 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "ouch" 3 | version = "0.6.1" 4 | authors = [ 5 | "João Marcos ", 6 | "Vinícius Rodrigues Miguel ", 7 | ] 8 | edition = "2021" 9 | readme = "README.md" 10 | repository = "https://github.com/ouch-org/ouch" 11 | license = "MIT" 12 | keywords = ["decompression", "compression", "cli"] 13 | categories = ["command-line-utilities", "compression", "encoding"] 14 | description = "A command-line utility for easily compressing and decompressing files and directories." 15 | 16 | [dependencies] 17 | atty = "0.2.14" 18 | brotli = "7.0.0" 19 | bstr = { version = "1.10.0", default-features = false, features = ["std"] } 20 | bytesize = "1.3.0" 21 | bzip2 = "0.4.4" 22 | bzip3 = { version = "0.9.0", features = ["bundled"], optional = true } 23 | clap = { version = "4.5.20", features = ["derive", "env"] } 24 | filetime_creation = "0.2" 25 | flate2 = { version = "1.0.30", default-features = false } 26 | fs-err = "2.11.0" 27 | gzp = { version = "0.11.3", default-features = false, features = [ 28 | "snappy_default", 29 | ] } 30 | ignore = "0.4.23" 31 | libc = "0.2.155" 32 | linked-hash-map = "0.5.6" 33 | lz4_flex = "0.11.3" 34 | num_cpus = "1.16.0" 35 | once_cell = "1.20.2" 36 | rayon = "1.10.0" 37 | same-file = "1.0.6" 38 | sevenz-rust2 = { version = "0.13.1", features = ["compress", "aes256"] } 39 | snap = "1.1.1" 40 | tar = "0.4.42" 41 | tempfile = "3.10.1" 42 | time = { version = "0.3.36", default-features = false } 43 | unrar = { version = "0.5.7", optional = true } 44 | xz2 = "0.1.7" 45 | zip = { version = "0.6.6", default-features = false, features = [ 46 | "time", 47 | "aes-crypto", 48 | ] } 49 | zstd = { version = "0.13.2", default-features = false, features = ["zstdmt"] } 50 | 51 | [target.'cfg(not(unix))'.dependencies] 52 | is_executable = "1.0.1" 53 | 54 | [build-dependencies] 55 | clap = { version = "4.5.20", features = ["derive", "env", "string"] } 56 | clap_complete = "4.5.28" 57 | clap_mangen = "0.2.24" 58 | 59 | [dev-dependencies] 60 | assert_cmd = "2.0.14" 61 | glob = "0.3.2" 62 | infer = "0.16.0" 63 | insta = { version = "1.40.0", features = ["filters"] } 64 | itertools = "0.14.0" 65 | memchr = "2.7.4" 66 | parse-display = "0.9.1" 67 | pretty_assertions = "1.4.1" 68 | proptest = "1.5.0" 69 | rand = { version = "0.8.5", default-features = false, features = [ 70 | "small_rng", 71 | "std", 72 | ] } 73 | regex = "1.10.4" 74 | test-strategy = "0.4.0" 75 | 76 | [features] 77 | default = ["unrar", "use_zlib", "use_zstd_thin", "bzip3"] 78 | use_zlib = ["flate2/zlib", "gzp/deflate_zlib", "zip/deflate-zlib"] 79 | use_zstd_thin = ["zstd/thin"] 80 | allow_piped_choice = [] 81 | 82 | # For generating binaries for releases 83 | [profile.release] 84 | lto = true 85 | codegen-units = 1 86 | opt-level = 3 87 | strip = true 88 | 89 | # When we need a fast binary that compiles slightly faster `release` (useful for CI) 90 | [profile.fast] 91 | inherits = "release" 92 | lto = false 93 | opt-level = 2 94 | incremental = true 95 | codegen-units = 32 96 | strip = false 97 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [build.env] 2 | passthrough = ["RUSTFLAGS", "OUCH_ARTIFACTS_FOLDER"] 3 | 4 | [target.aarch64-unknown-linux-gnu] 5 | image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge" 6 | 7 | [target.armv7-unknown-linux-gnueabihf] 8 | image = "ghcr.io/cross-rs/armv7-unknown-linux-gnueabihf:edge" 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2023 Vinícius R. Miguel, João Marcos P. Bezerra and contributors 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | 23 | --- 24 | 25 | Copyright notices from other projects: 26 | 27 | Infer crate (MIT LICENSE): 28 | > Copyright (c) 2019 Bojan 29 | > Code at https://github.com/bojand/infer 30 | 31 | Bzip3-rs crate (LGPL 3.0): 32 | > Code for this crate is available at https://github.com/bczhc/bzip3-rs 33 | > See its license at https://github.com/bczhc/bzip3-rs/blob/master/LICENSE 34 | 35 | Bzip3 library (LGPL 3.0): 36 | > Code for this library is available at https://github.com/kspalaiologos/bzip3 37 | > See its license at https://github.com/kspalaiologos/bzip3/blob/master/LICENSE 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 | 3 | Crates.io link 4 | 5 | 6 | License 7 | 8 |

9 | 10 | # Ouch! 11 | 12 | `ouch` stands for **Obvious Unified Compression Helper**. 13 | 14 | It's a CLI tool for compressing and decompressing for various formats. 15 | 16 | - [Features](#features) 17 | - [Usage](#usage) 18 | - [Installation](#installation) 19 | - [Supported Formats](#supported-formats) 20 | - [Benchmarks](#benchmarks) 21 | - [Contributing](#contributing) 22 | 23 | # Features 24 | 25 | 1. Easy to use. 26 | 2. Fast. 27 | 3. Great error message feedback. 28 | 4. No runtime dependencies required (for _Linux x86_64_). 29 | 5. Accessibility mode ([see more](https://github.com/ouch-org/ouch/wiki/Accessibility)). 30 | 6. Shell completions and man pages. 31 | 32 | # Usage 33 | 34 | Ouch has three main subcommands: 35 | 36 | - `ouch decompress` (alias `d`) 37 | - `ouch compress` (alias `c`) 38 | - `ouch list` (alias `l` or `ls`) 39 | 40 | To see `help` for a specific command: 41 | 42 | ```sh 43 | ouch help 44 | ouch --help # equivalent 45 | ``` 46 | 47 | ## Decompressing 48 | 49 | Use the `decompress` subcommand, `ouch` will detect the extensions automatically. 50 | 51 | ```sh 52 | ouch decompress a.zip 53 | 54 | # Decompress multiple files 55 | ouch decompress a.zip b.tar.gz c.tar 56 | ``` 57 | 58 | The `-d/--dir` flag can be used to redirect decompression results to another directory. 59 | 60 | ```sh 61 | # Decompress 'summer_vacation.zip' inside of new folder 'pictures' 62 | ouch decompress summer_vacation.zip --dir pictures 63 | ``` 64 | 65 | ## Compressing 66 | 67 | Pass input files to the `compress` subcommand, add the **output file** at the end. 68 | 69 | ```sh 70 | # Compress two files into `archive.zip` 71 | ouch compress one.txt two.txt archive.zip 72 | 73 | # Compress file.txt using .lz4 and .zst 74 | ouch compress file.txt file.txt.lz4.zst 75 | ``` 76 | 77 | `ouch` detects the extensions of the **output file** to decide what formats to use. 78 | 79 | ## Listing 80 | 81 | ```sh 82 | ouch list archive.zip 83 | 84 | # Example with tree formatting 85 | ouch list source-code.zip --tree 86 | ``` 87 | 88 | Output: 89 | 90 | ``` 91 | └── src 92 | ├── archive 93 | │ ├── mod.rs 94 | │ ├── tar.rs 95 | │ └── zip.rs 96 | ├── utils 97 | │ ├── colors.rs 98 | │ ├── formatting.rs 99 | │ ├── mod.rs 100 | │ └── fs.rs 101 | ├── commands 102 | │ ├── list.rs 103 | │ ├── compress.rs 104 | │ ├── decompress.rs 105 | │ └── mod.rs 106 | ├── accessible.rs 107 | ├── error.rs 108 | ├── cli.rs 109 | └── main.rs 110 | ``` 111 | 112 | # Supported formats 113 | 114 | | Format | `.tar` | `.zip` | `7z` | `.gz` | `.xz`, `.lzma` | `.bz`, `.bz2` | `.bz3` | `.lz4` | `.sz` (Snappy) | `.zst` | `.rar` | `.br` | 115 | |:---------:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:|:---:| 116 | | Supported | ✓ | ✓¹ | ✓¹ | ✓² | ✓ | ✓ | ✓ | ✓ | ✓² | ✓² | ✓³ | ✓ | 117 | 118 | ✓: Supports compression and decompression. 119 | 120 | ✓¹: Due to limitations of the compression format itself, (de)compression can't be done with streaming. 121 | 122 | ✓²: Supported, and compression runs in parallel. 123 | 124 | ✓³: Due to RAR's restrictive license, only decompression and listing can be supported. 125 | If you wish to exclude non-free code from your build, you can disable RAR support 126 | by building without the `unrar` feature. 127 | 128 | `tar` aliases are also supported: `tgz`, `tbz`, `tbz2`, `tlz4`, `txz`, `tlzma`, `tsz`, `tzst`. 129 | 130 | Formats can be chained: 131 | 132 | - `.tar.gz` 133 | - `.tar.gz.xz.zst.gz.lz4.sz` 134 | 135 | If the filename has no extensions, `Ouch` will try to infer the format by the [file signature](https://en.wikipedia.org/wiki/List_of_file_signatures) and ask the user for confirmation. 136 | 137 | # Installation 138 | 139 | 140 | Packaging status 141 | 142 | 143 | ## On Arch Linux 144 | 145 | ```bash 146 | pacman -S ouch 147 | ``` 148 | 149 | ## On Windows via Scoop 150 | 151 | ```cmd 152 | scoop install ouch 153 | ``` 154 | 155 | ## From crates.io 156 | 157 | ```bash 158 | cargo install ouch 159 | ``` 160 | 161 | ## Download the latest release bundle 162 | 163 | Check the [releases page](https://github.com/ouch-org/ouch/releases). 164 | 165 | ## Compiling from source code 166 | 167 | Check the [wiki guide on compiling](https://github.com/ouch-org/ouch/wiki/Compiling-and-installing-from-source-code). 168 | 169 | # Runtime Dependencies 170 | 171 | If running `ouch` results in a linking error, it means you're missing a runtime dependency. 172 | 173 | If you're downloading binaries from the [releases page](https://github.com/ouch-org/ouch/releases), try the `musl` variants, those are static binaries that require no runtime dependencies. 174 | 175 | Otherwise, you'll need these libraries installed on your system: 176 | 177 | * [liblzma](https://www.7-zip.org/sdk.html) 178 | * [libbz2](https://www.sourceware.org/bzip2) 179 | * [libbz3](https://github.com/kspalaiologos/bzip3) 180 | * [libz](https://www.zlib.net) 181 | 182 | These should be available in your system's package manager. 183 | 184 | # Benchmarks 185 | 186 | Benchmark results are available [here](benchmarks/results.md). 187 | Performance of compressing and decompressing 188 | [Rust](https://github.com/rust-lang/rust) source code are measured and compared with 189 | [Hyperfine](https://github.com/sharkdp/hyperfine). 190 | The values presented are the average (wall clock) elapsed time. 191 | 192 | Note: `ouch` focuses heavily on usage ergonomics and nice error messages, but 193 | we plan on doing some optimization in the future. 194 | 195 | Versions used: 196 | 197 | - `ouch` _0.4.0_ 198 | - [`tar`] _1.34_ 199 | - [`unzip`][infozip] _6.00_ 200 | - [`zip`][infozip] _3.0_ 201 | 202 | # Contributing 203 | 204 | `ouch` is made out of voluntary work, contributors are very welcome! Contributions of all sizes are appreciated. 205 | 206 | - Open an [issue](https://github.com/ouch-org/ouch/issues). 207 | - Package it for your favorite distribution or package manager. 208 | - Share it with a friend! 209 | - Open a pull request. 210 | 211 | If you're creating a Pull Request, check [CONTRIBUTING.md](./CONTRIBUTING.md). 212 | 213 | [`tar`]: https://www.gnu.org/software/tar/ 214 | [infozip]: http://www.info-zip.org/ 215 | -------------------------------------------------------------------------------- /benchmarks/results.md: -------------------------------------------------------------------------------- 1 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 2 | |:---|---:|---:|---:|---:| 3 | | `ouch compress rust output.tar` | 445.3 ± 5.8 | 436.3 | 453.4 | 1.43 ± 0.05 | 4 | | `tar -cvf output.tar rust` | 311.6 ± 10.8 | 304.0 | 339.8 | 1.00 | 5 | 6 | | Command | Mean [s] | Min [s] | Max [s] | Relative | 7 | |:---|---:|---:|---:|---:| 8 | | `ouch decompress input.tar --dir output` | 1.393 ± 0.031 | 1.371 | 1.474 | 1.85 ± 0.05 | 9 | | `tar -xv -C output -f input.tar` | 0.751 ± 0.010 | 0.738 | 0.770 | 1.00 | 10 | 11 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 12 | |:---|---:|---:|---:|---:| 13 | | `ouch compress compiler output.tar.gz` | 667.9 ± 8.7 | 657.1 | 680.6 | 1.00 ± 0.02 | 14 | | `tar -cvzf output.tar.gz compiler` | 667.8 ± 8.8 | 656.9 | 685.3 | 1.00 | 15 | 16 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 17 | |:---|---:|---:|---:|---:| 18 | | `ouch decompress input.tar.gz --dir output` | 170.7 ± 2.0 | 165.8 | 173.1 | 1.25 ± 0.03 | 19 | | `tar -xvz -C output -f input.tar.gz` | 136.2 ± 2.2 | 132.8 | 141.1 | 1.00 | 20 | 21 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 22 | |:---|---:|---:|---:|---:| 23 | | `ouch compress compiler output.zip` | 549.7 ± 4.3 | 543.6 | 558.6 | 1.00 | 24 | | `zip output.zip -r compiler` | 581.3 ± 9.1 | 573.2 | 600.9 | 1.06 ± 0.02 | 25 | 26 | | Command | Mean [ms] | Min [ms] | Max [ms] | Relative | 27 | |:---|---:|---:|---:|---:| 28 | | `ouch decompress input.zip --dir output` | 171.3 ± 2.4 | 166.9 | 174.3 | 1.00 | 29 | | `unzip input.zip -d output` | 218.3 ± 4.9 | 211.9 | 229.3 | 1.27 ± 0.03 | 30 | -------------------------------------------------------------------------------- /benchmarks/run-benchmarks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | # 3 | # Input files used: 4 | # - `compiler` (27 MB) for compressed formats. 5 | # - `rust` (229 MB) for uncompressed formats. 6 | # 7 | # Compressed formats benchmarked: 8 | # - .tar.gz 9 | # - .zip 10 | # 11 | # Uncompressed formats benchmarked: 12 | # - .tar 13 | 14 | set -e 15 | 16 | DESCOMPRESSION_CLEANUP="rm output -r" 17 | 18 | function call_hyperfine() { 19 | hyperfine "$@" \ 20 | --warmup 4 \ 21 | --export-markdown "${FUNCNAME[1]}.md" 22 | } 23 | 24 | function tar_compression() { 25 | cleanup="rm output.tar" 26 | 27 | call_hyperfine \ 28 | 'ouch compress rust output.tar' \ 29 | 'tar -cvf output.tar rust' \ 30 | --prepare "$cleanup || true" 31 | 32 | $cleanup 33 | } 34 | 35 | function tar_decompression() { 36 | echo "Creating tar archive to benchmark decompression..." 37 | ouch compress rust input.tar --yes &> /dev/null 38 | 39 | call_hyperfine \ 40 | 'ouch decompress input.tar --dir output' \ 41 | 'tar -xv -C output -f input.tar' \ 42 | --prepare "$DESCOMPRESSION_CLEANUP || true" \ 43 | --prepare "$DESCOMPRESSION_CLEANUP || true ; mkdir output" 44 | 45 | $DESCOMPRESSION_CLEANUP 46 | } 47 | 48 | function tar_gz_compression() { 49 | cleanup="rm output.tar.gz" 50 | 51 | call_hyperfine \ 52 | 'ouch compress compiler output.tar.gz' \ 53 | 'tar -cvzf output.tar.gz compiler' \ 54 | --prepare "$cleanup || true" 55 | 56 | $cleanup 57 | } 58 | 59 | function tar_gz_decompression() { 60 | echo "Creating tar.gz archive to benchmark decompression..." 61 | ouch compress compiler input.tar.gz --yes &> /dev/null 62 | 63 | call_hyperfine \ 64 | 'ouch decompress input.tar.gz --dir output' \ 65 | 'tar -xvz -C output -f input.tar.gz' \ 66 | --prepare "$DESCOMPRESSION_CLEANUP || true" \ 67 | --prepare "$DESCOMPRESSION_CLEANUP || true ; mkdir output" 68 | 69 | $DESCOMPRESSION_CLEANUP 70 | } 71 | 72 | function zip_compression() { 73 | cleanup="rm output.zip" 74 | 75 | call_hyperfine \ 76 | 'zip output.zip -r compiler' \ 77 | 'ouch compress compiler output.zip' \ 78 | --prepare "$cleanup || true" 79 | 80 | $cleanup 81 | } 82 | 83 | function zip_decompression() { 84 | echo "Creating zip archive to benchmark decompression..." 85 | ouch compress compiler input.zip --yes &> /dev/null 86 | 87 | call_hyperfine \ 88 | 'ouch decompress input.zip --dir output' \ 89 | 'unzip input.zip -d output' \ 90 | --prepare "$DESCOMPRESSION_CLEANUP || true" 91 | 92 | $DESCOMPRESSION_CLEANUP 93 | } 94 | 95 | function run_benches() { 96 | tar_compression 97 | tar_decompression 98 | tar_gz_compression 99 | tar_gz_decompression 100 | zip_compression 101 | zip_decompression 102 | } 103 | 104 | function concatenate_results() { 105 | cat tar_compression.md <(echo) \ 106 | tar_decompression.md <(echo) \ 107 | tar_gz_compression.md <(echo) \ 108 | tar_gz_decompression.md <(echo) \ 109 | zip_compression.md <(echo) \ 110 | zip_decompression.md > results.md 111 | } 112 | 113 | run_benches 114 | concatenate_results 115 | 116 | echo 117 | echo "check results at results.md" 118 | -------------------------------------------------------------------------------- /benchmarks/setup-benchmarks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | # Run this script inside of the folder `benchmarks` to download 4 | # the input files to run the benchmarks. 5 | # 6 | # ``` 7 | # cd benchmarks 8 | # ./setup-benchmarks.sh 9 | # ``` 10 | # 11 | # It will download rust-lang's source code. 12 | # 13 | # After this, you can run `./run-benchmarks.sh`. 14 | # 15 | # Input files downloaded: 16 | # - `compiler` (27 MB) for compressed formats. 17 | # - `rust` (229 MB) for uncompressed formats. 18 | 19 | set -e 20 | 21 | function setup() { 22 | if [[ -d "rust" || -d "compiler" ]]; then 23 | echo "Input files already exist, try deleting before downloading again." 24 | exit 1 25 | fi 26 | 27 | # Download the Rust 1.65.0 source code 28 | git clone -b 1.65.0 https://github.com/rust-lang/rust --depth 1 29 | 30 | # Delete write-protected files to make benchmark cleanup simpler 31 | rm rust/.git -fr 32 | 33 | # Separate the compiler code 34 | cp rust/compiler -r compiler 35 | } 36 | 37 | setup 38 | 39 | echo "tip: if you see a git warning above, you can ignore it" 40 | -------------------------------------------------------------------------------- /benchmarks/shell.nix: -------------------------------------------------------------------------------- 1 | { pkgs ? import { } }: 2 | 3 | with pkgs; 4 | 5 | let 6 | ouch = rustPlatform.buildRustPackage { 7 | pname = "ouch"; 8 | inherit ((lib.importTOML ../Cargo.toml).package) version; 9 | src = ../.; 10 | cargoLock.lockFile = ../Cargo.lock; 11 | }; 12 | in 13 | 14 | mkShell { 15 | packages = [ 16 | gnutar 17 | hyperfine 18 | ouch 19 | unzip 20 | zip 21 | ]; 22 | } 23 | -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | /// This build script checks for env vars to build ouch with shell completions and man pages. 2 | /// 3 | /// # How to generate shell completions and man pages: 4 | /// 5 | /// Set `OUCH_ARTIFACTS_FOLDER` to the name of the destination folder: 6 | /// 7 | /// ```sh 8 | /// OUCH_ARTIFACTS_FOLDER=man-page-and-completions-artifacts cargo build 9 | /// ``` 10 | /// 11 | /// All completion files will be generated inside of the folder "man-page-and-completions-artifacts". 12 | /// 13 | /// If the folder does not exist, it will be created. 14 | use std::{ 15 | env, 16 | fs::{create_dir_all, File}, 17 | path::Path, 18 | }; 19 | 20 | use clap::{CommandFactory, ValueEnum}; 21 | use clap_complete::{generate_to, Shell}; 22 | use clap_mangen::Man; 23 | 24 | include!("src/cli/args.rs"); 25 | 26 | fn main() { 27 | println!("cargo:rerun-if-env-changed=OUCH_ARTIFACTS_FOLDER"); 28 | 29 | if let Some(dir) = env::var_os("OUCH_ARTIFACTS_FOLDER") { 30 | let out = &Path::new(&dir); 31 | create_dir_all(out).unwrap(); 32 | let cmd = &mut CliArgs::command(); 33 | 34 | Man::new(cmd.clone()) 35 | .render(&mut File::create(out.join("ouch.1")).unwrap()) 36 | .unwrap(); 37 | 38 | for subcmd in cmd.get_subcommands() { 39 | let name = format!("ouch-{}", subcmd.get_name()); 40 | Man::new(subcmd.clone().name(&name)) 41 | .render(&mut File::create(out.join(format!("{name}.1"))).unwrap()) 42 | .unwrap(); 43 | } 44 | 45 | for shell in Shell::value_variants() { 46 | generate_to(*shell, cmd, "ouch", out).unwrap(); 47 | } 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /rustfmt.toml: -------------------------------------------------------------------------------- 1 | # Stable features 2 | max_width = 120 3 | use_field_init_shorthand = true 4 | newline_style = "Unix" 5 | edition = "2021" 6 | reorder_imports = true 7 | reorder_modules = true 8 | use_try_shorthand = true 9 | 10 | # Unstable features (nightly only) 11 | unstable_features = true 12 | group_imports = "StdExternalCrate" 13 | imports_granularity = "Crate" 14 | -------------------------------------------------------------------------------- /scripts/package-release-assets.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -e 3 | 4 | mkdir output_assets 5 | echo "created folder 'output_assets/'" 6 | ls -lA -w 1 7 | cd downloaded_artifacts 8 | echo "entered 'downloaded_artifacts/'" 9 | ls -lA -w 1 10 | 11 | PLATFORMS=( 12 | "aarch64-pc-windows-msvc" 13 | "aarch64-unknown-linux-gnu" 14 | "aarch64-unknown-linux-musl" 15 | "armv7-unknown-linux-gnueabihf" 16 | "armv7-unknown-linux-musleabihf" 17 | "x86_64-apple-darwin" 18 | "x86_64-pc-windows-gnu" 19 | "x86_64-pc-windows-msvc" 20 | "x86_64-unknown-linux-gnu" 21 | "x86_64-unknown-linux-musl" 22 | ) 23 | # TODO: remove allow_piped_choice later 24 | DEFAULT_FEATURES="allow_piped_choice+unrar+use_zlib+use_zstd_thin+bzip3" 25 | 26 | for platform in "${PLATFORMS[@]}"; do 27 | path="ouch-${platform}" 28 | echo "Processing $path" 29 | 30 | if [ ! -d "${path}-${DEFAULT_FEATURES}" ]; then 31 | echo "ERROR: Could not find artifact directory for $platform with default features ($path)" 32 | exit 1 33 | fi 34 | mv "${path}-${DEFAULT_FEATURES}" "$path" # remove the annoying suffix 35 | 36 | cp ../{README.md,LICENSE,CHANGELOG.md} "$path" 37 | mkdir -p "$path/man" 38 | mkdir -p "$path/completions" 39 | 40 | mv "$path"/man-page-and-completions-artifacts/*.1 "$path/man" 41 | mv "$path"/man-page-and-completions-artifacts/* "$path/completions" 42 | rm -r "$path/man-page-and-completions-artifacts" 43 | 44 | if [[ "$platform" == *"-windows-"* ]]; then 45 | mv "$path/target/$platform/release/ouch.exe" "$path" 46 | rm -rf "$path/target" 47 | 48 | zip -r "../output_assets/${path}.zip" "$path" 49 | echo "Created output_assets/${path}.zip" 50 | else 51 | mv "$path/target/$platform/release/ouch" "$path" 52 | rm -rf "$path/target" 53 | chmod +x "$path/ouch" 54 | 55 | tar czf "../output_assets/${path}.tar.gz" "$path" 56 | echo "Created output_assets/${path}.tar.gz" 57 | fi 58 | done 59 | 60 | echo "Done." 61 | -------------------------------------------------------------------------------- /src/accessible.rs: -------------------------------------------------------------------------------- 1 | //! Accessibility mode functions. 2 | //! 3 | //! # Problem 4 | //! 5 | //! `Ouch`'s default output contains symbols which make it visually easier to 6 | //! read, but harder for people who are visually impaired and rely on 7 | //! text-to-voice readers. 8 | //! 9 | //! On top of that, people who use text-to-voice tools can't easily skim 10 | //! through verbose lines of text, so they strongly benefit from fewer lines 11 | //! of output. 12 | //! 13 | //! # Solution 14 | //! 15 | //! To tackle that, `Ouch` has an accessibility mode that filters out most of 16 | //! the verbose logging, displaying only the most important pieces of 17 | //! information. 18 | //! 19 | //! Accessible mode also changes how logs are displayed, to remove symbols 20 | //! which are "noise" to text-to-voice tools and change formatting of error 21 | //! messages. 22 | //! 23 | //! # Are impaired people actually benefiting from this? 24 | //! 25 | //! So far we don't know. Most CLI tools aren't accessible, so we can't expect 26 | //! many impaired people to be using the terminal and CLI tools, including 27 | //! `Ouch`. 28 | //! 29 | //! I consider this to be an experiment, and a tiny step towards the right 30 | //! direction, `Ouch` shows that this is possible and easy to do, hopefully 31 | //! we can use our experience to later create guides or libraries for other 32 | //! developers. 33 | 34 | use once_cell::sync::OnceCell; 35 | 36 | /// Global flag for accessible mode. 37 | pub static ACCESSIBLE: OnceCell = OnceCell::new(); 38 | 39 | /// Check if `Ouch` is running in accessible mode. 40 | /// 41 | /// Check the module-level documentation for more details. 42 | pub fn is_running_in_accessible_mode() -> bool { 43 | ACCESSIBLE.get().copied().unwrap_or(false) 44 | } 45 | 46 | /// Set the value of the global [`ACCESSIBLE`] flag. 47 | /// 48 | /// Check the module-level documentation for more details. 49 | pub fn set_accessible(value: bool) { 50 | if ACCESSIBLE.get().is_none() { 51 | ACCESSIBLE.set(value).unwrap(); 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/archive/bzip3_stub.rs: -------------------------------------------------------------------------------- 1 | use crate::Error; 2 | 3 | pub fn no_support() -> Error { 4 | Error::UnsupportedFormat { 5 | reason: "BZip3 support is disabled for this build, possibly due to missing bindgen-cli dependency.".into(), 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/archive/mod.rs: -------------------------------------------------------------------------------- 1 | //! Archive compression algorithms 2 | 3 | #[cfg(not(feature = "bzip3"))] 4 | pub mod bzip3_stub; 5 | #[cfg(feature = "unrar")] 6 | pub mod rar; 7 | #[cfg(not(feature = "unrar"))] 8 | pub mod rar_stub; 9 | pub mod sevenz; 10 | pub mod tar; 11 | pub mod zip; 12 | -------------------------------------------------------------------------------- /src/archive/rar.rs: -------------------------------------------------------------------------------- 1 | //! Contains RAR-specific building and unpacking functions 2 | 3 | use std::path::Path; 4 | 5 | use unrar::Archive; 6 | 7 | use crate::{ 8 | error::{Error, Result}, 9 | list::FileInArchive, 10 | utils::{logger::info, Bytes}, 11 | }; 12 | 13 | /// Unpacks the archive given by `archive_path` into the folder given by `output_folder`. 14 | /// Assumes that output_folder is empty 15 | pub fn unpack_archive( 16 | archive_path: &Path, 17 | output_folder: &Path, 18 | password: Option<&[u8]>, 19 | quiet: bool, 20 | ) -> crate::Result { 21 | let archive = match password { 22 | Some(password) => Archive::with_password(archive_path, password), 23 | None => Archive::new(archive_path), 24 | }; 25 | 26 | let mut archive = archive.open_for_processing()?; 27 | let mut unpacked = 0; 28 | 29 | while let Some(header) = archive.read_header()? { 30 | let entry = header.entry(); 31 | archive = if entry.is_file() { 32 | if !quiet { 33 | info(format!( 34 | "extracted ({}) {}", 35 | Bytes::new(entry.unpacked_size), 36 | entry.filename.display(), 37 | )); 38 | } 39 | unpacked += 1; 40 | header.extract_with_base(output_folder)? 41 | } else { 42 | header.skip()? 43 | }; 44 | } 45 | 46 | Ok(unpacked) 47 | } 48 | 49 | /// List contents of `archive_path`, returning a vector of archive entries 50 | pub fn list_archive( 51 | archive_path: &Path, 52 | password: Option<&[u8]>, 53 | ) -> Result>> { 54 | let archive = match password { 55 | Some(password) => Archive::with_password(archive_path, password), 56 | None => Archive::new(archive_path), 57 | }; 58 | 59 | Ok(archive.open_for_listing()?.map(|item| { 60 | let item = item?; 61 | let is_dir = item.is_directory(); 62 | let path = item.filename; 63 | 64 | Ok(FileInArchive { path, is_dir }) 65 | })) 66 | } 67 | 68 | pub fn no_compression() -> Error { 69 | Error::UnsupportedFormat { 70 | reason: "Creating RAR archives is not allowed due to licensing restrictions.".into(), 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/archive/rar_stub.rs: -------------------------------------------------------------------------------- 1 | use crate::Error; 2 | 3 | pub fn no_support() -> Error { 4 | Error::UnsupportedFormat { 5 | reason: "RAR support is disabled for this build, possibly due to licensing restrictions.".into(), 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /src/archive/sevenz.rs: -------------------------------------------------------------------------------- 1 | //! SevenZip archive format compress function 2 | 3 | use std::{ 4 | env, 5 | io::{self, Read, Seek, Write}, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use bstr::ByteSlice; 10 | use fs_err as fs; 11 | use same_file::Handle; 12 | use sevenz_rust2::SevenZArchiveEntry; 13 | 14 | use crate::{ 15 | error::{Error, FinalError, Result}, 16 | list::FileInArchive, 17 | utils::{ 18 | cd_into_same_dir_as, 19 | logger::{info, warning}, 20 | Bytes, EscapedPathDisplay, FileVisibilityPolicy, 21 | }, 22 | }; 23 | 24 | pub fn compress_sevenz( 25 | files: &[PathBuf], 26 | output_path: &Path, 27 | writer: W, 28 | file_visibility_policy: FileVisibilityPolicy, 29 | quiet: bool, 30 | ) -> crate::Result 31 | where 32 | W: Write + Seek, 33 | { 34 | let mut writer = sevenz_rust2::SevenZWriter::new(writer)?; 35 | let output_handle = Handle::from_path(output_path); 36 | 37 | for filename in files { 38 | let previous_location = cd_into_same_dir_as(filename)?; 39 | 40 | // Unwrap safety: 41 | // paths should be canonicalized by now, and the root directory rejected. 42 | let filename = filename.file_name().unwrap(); 43 | 44 | for entry in file_visibility_policy.build_walker(filename) { 45 | let entry = entry?; 46 | let path = entry.path(); 47 | 48 | // If the output_path is the same as the input file, warn the user and skip the input (in order to avoid compression recursion) 49 | if let Ok(handle) = &output_handle { 50 | if matches!(Handle::from_path(path), Ok(x) if &x == handle) { 51 | warning(format!( 52 | "Cannot compress `{}` into itself, skipping", 53 | output_path.display() 54 | )); 55 | 56 | continue; 57 | } 58 | } 59 | 60 | // This is printed for every file in `input_filenames` and has 61 | // little importance for most users, but would generate lots of 62 | // spoken text for users using screen readers, braille displays 63 | // and so on 64 | if !quiet { 65 | info(format!("Compressing '{}'", EscapedPathDisplay::new(path))); 66 | } 67 | 68 | let metadata = match path.metadata() { 69 | Ok(metadata) => metadata, 70 | Err(e) => { 71 | if e.kind() == std::io::ErrorKind::NotFound && path.is_symlink() { 72 | // This path is for a broken symlink, ignore it 73 | continue; 74 | } 75 | return Err(e.into()); 76 | } 77 | }; 78 | 79 | let entry_name = path.to_str().ok_or_else(|| { 80 | FinalError::with_title("7z requires that all entry names are valid UTF-8") 81 | .detail(format!("File at '{path:?}' has a non-UTF-8 name")) 82 | })?; 83 | 84 | let entry = sevenz_rust2::SevenZArchiveEntry::from_path(path, entry_name.to_owned()); 85 | let entry_data = if metadata.is_dir() { 86 | None 87 | } else { 88 | Some(fs::File::open(path)?) 89 | }; 90 | 91 | writer.push_archive_entry::(entry, entry_data)?; 92 | } 93 | 94 | env::set_current_dir(previous_location)?; 95 | } 96 | 97 | let bytes = writer.finish()?; 98 | Ok(bytes) 99 | } 100 | 101 | pub fn decompress_sevenz(reader: R, output_path: &Path, password: Option<&[u8]>, quiet: bool) -> crate::Result 102 | where 103 | R: Read + Seek, 104 | { 105 | let mut count: usize = 0; 106 | 107 | let entry_extract_fn = |entry: &SevenZArchiveEntry, reader: &mut dyn Read, path: &PathBuf| { 108 | count += 1; 109 | // Manually handle writing all files from 7z archive, due to library exluding empty files 110 | use std::io::BufWriter; 111 | 112 | use filetime_creation as ft; 113 | 114 | let file_path = output_path.join(entry.name()); 115 | 116 | if entry.is_directory() { 117 | if !quiet { 118 | info(format!( 119 | "File {} extracted to \"{}\"", 120 | entry.name(), 121 | file_path.display() 122 | )); 123 | } 124 | if !path.exists() { 125 | fs::create_dir_all(path)?; 126 | } 127 | } else { 128 | if !quiet { 129 | info(format!( 130 | "extracted ({}) {:?}", 131 | Bytes::new(entry.size()), 132 | file_path.display(), 133 | )); 134 | } 135 | 136 | if let Some(parent) = path.parent() { 137 | if !parent.exists() { 138 | fs::create_dir_all(parent)?; 139 | } 140 | } 141 | 142 | let file = fs::File::create(path)?; 143 | let mut writer = BufWriter::new(file); 144 | io::copy(reader, &mut writer)?; 145 | 146 | ft::set_file_handle_times( 147 | writer.get_ref().file(), 148 | Some(ft::FileTime::from_system_time(entry.access_date().into())), 149 | Some(ft::FileTime::from_system_time(entry.last_modified_date().into())), 150 | Some(ft::FileTime::from_system_time(entry.creation_date().into())), 151 | ) 152 | .unwrap_or_default(); 153 | } 154 | 155 | Ok(true) 156 | }; 157 | 158 | match password { 159 | Some(password) => sevenz_rust2::decompress_with_extract_fn_and_password( 160 | reader, 161 | output_path, 162 | sevenz_rust2::Password::from(password.to_str().map_err(|err| Error::InvalidPassword { 163 | reason: err.to_string(), 164 | })?), 165 | entry_extract_fn, 166 | )?, 167 | None => sevenz_rust2::decompress_with_extract_fn(reader, output_path, entry_extract_fn)?, 168 | } 169 | 170 | Ok(count) 171 | } 172 | 173 | /// List contents of `archive_path`, returning a vector of archive entries 174 | pub fn list_archive(reader: R, password: Option<&[u8]>) -> Result>> 175 | where 176 | R: Read + Seek, 177 | { 178 | let mut files = Vec::new(); 179 | 180 | let entry_extract_fn = |entry: &SevenZArchiveEntry, _: &mut dyn Read, _: &PathBuf| { 181 | files.push(Ok(FileInArchive { 182 | path: entry.name().into(), 183 | is_dir: entry.is_directory(), 184 | })); 185 | Ok(true) 186 | }; 187 | 188 | match password { 189 | Some(password) => { 190 | let password = match password.to_str() { 191 | Ok(p) => p, 192 | Err(err) => { 193 | return Err(Error::InvalidPassword { 194 | reason: err.to_string(), 195 | }) 196 | } 197 | }; 198 | sevenz_rust2::decompress_with_extract_fn_and_password( 199 | reader, 200 | ".", 201 | sevenz_rust2::Password::from(password), 202 | entry_extract_fn, 203 | )?; 204 | } 205 | None => sevenz_rust2::decompress_with_extract_fn(reader, ".", entry_extract_fn)?, 206 | } 207 | 208 | Ok(files.into_iter()) 209 | } 210 | -------------------------------------------------------------------------------- /src/archive/tar.rs: -------------------------------------------------------------------------------- 1 | //! Contains Tar-specific building and unpacking functions 2 | 3 | use std::{ 4 | env, 5 | io::prelude::*, 6 | path::{Path, PathBuf}, 7 | sync::mpsc::{self, Receiver}, 8 | thread, 9 | }; 10 | 11 | use fs_err as fs; 12 | use same_file::Handle; 13 | 14 | use crate::{ 15 | error::FinalError, 16 | list::FileInArchive, 17 | utils::{ 18 | self, 19 | logger::{info, warning}, 20 | Bytes, EscapedPathDisplay, FileVisibilityPolicy, 21 | }, 22 | }; 23 | 24 | /// Unpacks the archive given by `archive` into the folder given by `into`. 25 | /// Assumes that output_folder is empty 26 | pub fn unpack_archive(reader: Box, output_folder: &Path, quiet: bool) -> crate::Result { 27 | let mut archive = tar::Archive::new(reader); 28 | 29 | let mut files_unpacked = 0; 30 | for file in archive.entries()? { 31 | let mut file = file?; 32 | 33 | match file.header().entry_type() { 34 | tar::EntryType::Symlink => { 35 | let relative_path = file.path()?.to_path_buf(); 36 | let full_path = output_folder.join(&relative_path); 37 | let target = file 38 | .link_name()? 39 | .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::InvalidData, "Missing symlink target"))?; 40 | 41 | #[cfg(unix)] 42 | std::os::unix::fs::symlink(&target, &full_path)?; 43 | #[cfg(windows)] 44 | std::os::windows::fs::symlink_file(&target, &full_path)?; 45 | } 46 | tar::EntryType::Regular | tar::EntryType::Directory => { 47 | file.unpack_in(output_folder)?; 48 | } 49 | _ => continue, 50 | } 51 | 52 | // This is printed for every file in the archive and has little 53 | // importance for most users, but would generate lots of 54 | // spoken text for users using screen readers, braille displays 55 | // and so on 56 | if !quiet { 57 | info(format!( 58 | "extracted ({}) {:?}", 59 | Bytes::new(file.size()), 60 | utils::strip_cur_dir(&output_folder.join(file.path()?)), 61 | )); 62 | } 63 | files_unpacked += 1; 64 | } 65 | 66 | Ok(files_unpacked) 67 | } 68 | 69 | /// List contents of `archive`, returning a vector of archive entries 70 | pub fn list_archive( 71 | mut archive: tar::Archive, 72 | ) -> impl Iterator> { 73 | struct Files(Receiver>); 74 | impl Iterator for Files { 75 | type Item = crate::Result; 76 | 77 | fn next(&mut self) -> Option { 78 | self.0.recv().ok() 79 | } 80 | } 81 | 82 | let (tx, rx) = mpsc::channel(); 83 | thread::spawn(move || { 84 | for file in archive.entries().expect("entries is only used once") { 85 | let file_in_archive = (|| { 86 | let file = file?; 87 | let path = file.path()?.into_owned(); 88 | let is_dir = file.header().entry_type().is_dir(); 89 | Ok(FileInArchive { path, is_dir }) 90 | })(); 91 | tx.send(file_in_archive).unwrap(); 92 | } 93 | }); 94 | 95 | Files(rx) 96 | } 97 | 98 | /// Compresses the archives given by `input_filenames` into the file given previously to `writer`. 99 | pub fn build_archive_from_paths( 100 | input_filenames: &[PathBuf], 101 | output_path: &Path, 102 | writer: W, 103 | file_visibility_policy: FileVisibilityPolicy, 104 | quiet: bool, 105 | follow_symlinks: bool, 106 | ) -> crate::Result 107 | where 108 | W: Write, 109 | { 110 | let mut builder = tar::Builder::new(writer); 111 | let output_handle = Handle::from_path(output_path); 112 | 113 | for filename in input_filenames { 114 | let previous_location = utils::cd_into_same_dir_as(filename)?; 115 | 116 | // Unwrap safety: 117 | // paths should be canonicalized by now, and the root directory rejected. 118 | let filename = filename.file_name().unwrap(); 119 | 120 | for entry in file_visibility_policy.build_walker(filename) { 121 | let entry = entry?; 122 | let path = entry.path(); 123 | 124 | // If the output_path is the same as the input file, warn the user and skip the input (in order to avoid compression recursion) 125 | if let Ok(handle) = &output_handle { 126 | if matches!(Handle::from_path(path), Ok(x) if &x == handle) { 127 | warning(format!( 128 | "Cannot compress `{}` into itself, skipping", 129 | output_path.display() 130 | )); 131 | 132 | continue; 133 | } 134 | } 135 | 136 | // This is printed for every file in `input_filenames` and has 137 | // little importance for most users, but would generate lots of 138 | // spoken text for users using screen readers, braille displays 139 | // and so on 140 | if !quiet { 141 | info(format!("Compressing '{}'", EscapedPathDisplay::new(path))); 142 | } 143 | 144 | if path.is_dir() { 145 | builder.append_dir(path, path)?; 146 | } else if path.is_symlink() && !follow_symlinks { 147 | let target_path = path.read_link()?; 148 | 149 | let mut header = tar::Header::new_gnu(); 150 | header.set_entry_type(tar::EntryType::Symlink); 151 | header.set_size(0); 152 | 153 | builder.append_link(&mut header, path, &target_path).map_err(|err| { 154 | FinalError::with_title("Could not create archive") 155 | .detail("Unexpected error while trying to read link") 156 | .detail(format!("Error: {err}.")) 157 | })?; 158 | } else { 159 | let mut file = match fs::File::open(path) { 160 | Ok(f) => f, 161 | Err(e) => { 162 | if e.kind() == std::io::ErrorKind::NotFound && path.is_symlink() { 163 | // This path is for a broken symlink, ignore it 164 | continue; 165 | } 166 | return Err(e.into()); 167 | } 168 | }; 169 | builder.append_file(path, file.file_mut()).map_err(|err| { 170 | FinalError::with_title("Could not create archive") 171 | .detail("Unexpected error while trying to read file") 172 | .detail(format!("Error: {err}.")) 173 | })?; 174 | } 175 | } 176 | env::set_current_dir(previous_location)?; 177 | } 178 | 179 | Ok(builder.into_inner()?) 180 | } 181 | -------------------------------------------------------------------------------- /src/archive/zip.rs: -------------------------------------------------------------------------------- 1 | //! Contains Zip-specific building and unpacking functions 2 | 3 | #[cfg(unix)] 4 | use std::os::unix::fs::PermissionsExt; 5 | use std::{ 6 | env, 7 | io::{self, prelude::*}, 8 | path::{Path, PathBuf}, 9 | sync::mpsc, 10 | thread, 11 | }; 12 | 13 | use filetime_creation::{set_file_mtime, FileTime}; 14 | use fs_err as fs; 15 | use same_file::Handle; 16 | use time::OffsetDateTime; 17 | use zip::{self, read::ZipFile, DateTime, ZipArchive}; 18 | 19 | use crate::{ 20 | error::FinalError, 21 | list::FileInArchive, 22 | utils::{ 23 | cd_into_same_dir_as, get_invalid_utf8_paths, 24 | logger::{info, info_accessible, warning}, 25 | pretty_format_list_of_paths, strip_cur_dir, Bytes, EscapedPathDisplay, FileVisibilityPolicy, 26 | }, 27 | }; 28 | 29 | /// Unpacks the archive given by `archive` into the folder given by `output_folder`. 30 | /// Assumes that output_folder is empty 31 | pub fn unpack_archive( 32 | mut archive: ZipArchive, 33 | output_folder: &Path, 34 | password: Option<&[u8]>, 35 | quiet: bool, 36 | ) -> crate::Result 37 | where 38 | R: Read + Seek, 39 | { 40 | let mut unpacked_files = 0; 41 | 42 | for idx in 0..archive.len() { 43 | let mut file = match password { 44 | Some(password) => archive 45 | .by_index_decrypt(idx, password)? 46 | .map_err(|_| zip::result::ZipError::UnsupportedArchive("Password required to decrypt file"))?, 47 | None => archive.by_index(idx)?, 48 | }; 49 | let file_path = match file.enclosed_name() { 50 | Some(path) => path.to_owned(), 51 | None => continue, 52 | }; 53 | 54 | let file_path = output_folder.join(file_path); 55 | 56 | display_zip_comment_if_exists(&file); 57 | 58 | match file.name().ends_with('/') { 59 | _is_dir @ true => { 60 | // This is printed for every file in the archive and has little 61 | // importance for most users, but would generate lots of 62 | // spoken text for users using screen readers, braille displays 63 | // and so on 64 | if !quiet { 65 | info(format!("File {} extracted to \"{}\"", idx, file_path.display())); 66 | } 67 | fs::create_dir_all(&file_path)?; 68 | } 69 | _is_file @ false => { 70 | if let Some(path) = file_path.parent() { 71 | if !path.exists() { 72 | fs::create_dir_all(path)?; 73 | } 74 | } 75 | let file_path = strip_cur_dir(file_path.as_path()); 76 | 77 | // same reason is in _is_dir: long, often not needed text 78 | if !quiet { 79 | info(format!( 80 | "extracted ({}) {:?}", 81 | Bytes::new(file.size()), 82 | file_path.display(), 83 | )); 84 | } 85 | 86 | let mode = file.unix_mode(); 87 | let is_symlink = mode.is_some_and(|mode| mode & 0o170000 == 0o120000); 88 | 89 | if is_symlink { 90 | let mut target = String::new(); 91 | file.read_to_string(&mut target)?; 92 | 93 | #[cfg(unix)] 94 | std::os::unix::fs::symlink(&target, file_path)?; 95 | #[cfg(windows)] 96 | std::os::windows::fs::symlink_file(&target, file_path)?; 97 | } else { 98 | let mut output_file = fs::File::create(file_path)?; 99 | io::copy(&mut file, &mut output_file)?; 100 | } 101 | 102 | set_last_modified_time(&file, file_path)?; 103 | } 104 | } 105 | 106 | #[cfg(unix)] 107 | unix_set_permissions(&file_path, &file)?; 108 | 109 | unpacked_files += 1; 110 | } 111 | 112 | Ok(unpacked_files) 113 | } 114 | 115 | /// List contents of `archive`, returning a vector of archive entries 116 | pub fn list_archive( 117 | mut archive: ZipArchive, 118 | password: Option<&[u8]>, 119 | ) -> impl Iterator> 120 | where 121 | R: Read + Seek + Send + 'static, 122 | { 123 | struct Files(mpsc::Receiver>); 124 | impl Iterator for Files { 125 | type Item = crate::Result; 126 | 127 | fn next(&mut self) -> Option { 128 | self.0.recv().ok() 129 | } 130 | } 131 | 132 | let password = password.map(|p| p.to_owned()); 133 | 134 | let (tx, rx) = mpsc::channel(); 135 | thread::spawn(move || { 136 | for idx in 0..archive.len() { 137 | let file_in_archive = (|| { 138 | let zip_result = match password.clone() { 139 | Some(password) => archive 140 | .by_index_decrypt(idx, &password)? 141 | .map_err(|_| zip::result::ZipError::UnsupportedArchive("Password required to decrypt file")), 142 | None => archive.by_index(idx), 143 | }; 144 | 145 | let file = match zip_result { 146 | Ok(f) => f, 147 | Err(e) => return Err(e.into()), 148 | }; 149 | 150 | let path = file.enclosed_name().unwrap_or(&*file.mangled_name()).to_owned(); 151 | let is_dir = file.is_dir(); 152 | 153 | Ok(FileInArchive { path, is_dir }) 154 | })(); 155 | tx.send(file_in_archive).unwrap(); 156 | } 157 | }); 158 | 159 | Files(rx) 160 | } 161 | 162 | /// Compresses the archives given by `input_filenames` into the file given previously to `writer`. 163 | pub fn build_archive_from_paths( 164 | input_filenames: &[PathBuf], 165 | output_path: &Path, 166 | writer: W, 167 | file_visibility_policy: FileVisibilityPolicy, 168 | quiet: bool, 169 | follow_symlinks: bool, 170 | ) -> crate::Result 171 | where 172 | W: Write + Seek, 173 | { 174 | let mut writer = zip::ZipWriter::new(writer); 175 | // always use ZIP64 to allow compression of files larger than 4GB 176 | // the format is widely supported and the extra 20B is negligible in most cases 177 | let options = zip::write::FileOptions::default().large_file(true); 178 | let output_handle = Handle::from_path(output_path); 179 | 180 | #[cfg(not(unix))] 181 | let executable = options.unix_permissions(0o755); 182 | 183 | // Vec of any filename that failed the UTF-8 check 184 | let invalid_unicode_filenames = get_invalid_utf8_paths(input_filenames); 185 | 186 | if !invalid_unicode_filenames.is_empty() { 187 | let error = FinalError::with_title("Cannot build zip archive") 188 | .detail("Zip archives require files to have valid UTF-8 paths") 189 | .detail(format!( 190 | "Files with invalid paths: {}", 191 | pretty_format_list_of_paths(&invalid_unicode_filenames) 192 | )); 193 | 194 | return Err(error.into()); 195 | } 196 | 197 | for filename in input_filenames { 198 | let previous_location = cd_into_same_dir_as(filename)?; 199 | 200 | // Unwrap safety: 201 | // paths should be canonicalized by now, and the root directory rejected. 202 | let filename = filename.file_name().unwrap(); 203 | 204 | for entry in file_visibility_policy.build_walker(filename) { 205 | let entry = entry?; 206 | let path = entry.path(); 207 | 208 | // If the output_path is the same as the input file, warn the user and skip the input (in order to avoid compression recursion) 209 | if let Ok(handle) = &output_handle { 210 | if matches!(Handle::from_path(path), Ok(x) if &x == handle) { 211 | warning(format!( 212 | "Cannot compress `{}` into itself, skipping", 213 | output_path.display() 214 | )); 215 | } 216 | } 217 | 218 | // This is printed for every file in `input_filenames` and has 219 | // little importance for most users, but would generate lots of 220 | // spoken text for users using screen readers, braille displays 221 | // and so on 222 | if !quiet { 223 | info(format!("Compressing '{}'", EscapedPathDisplay::new(path))); 224 | } 225 | 226 | let metadata = match path.metadata() { 227 | Ok(metadata) => metadata, 228 | Err(e) => { 229 | if e.kind() == std::io::ErrorKind::NotFound && path.is_symlink() { 230 | // This path is for a broken symlink, ignore it 231 | continue; 232 | } 233 | return Err(e.into()); 234 | } 235 | }; 236 | 237 | #[cfg(unix)] 238 | let mode = metadata.permissions().mode(); 239 | 240 | let entry_name = path.to_str().ok_or_else(|| { 241 | FinalError::with_title("Zip requires that all directories names are valid UTF-8") 242 | .detail(format!("File at '{path:?}' has a non-UTF-8 name")) 243 | })?; 244 | 245 | if metadata.is_dir() { 246 | writer.add_directory(entry_name, options)?; 247 | } else if path.is_symlink() && !follow_symlinks { 248 | let target_path = path.read_link()?; 249 | let target_name = target_path.to_str().ok_or_else(|| { 250 | FinalError::with_title("Zip requires that all directories names are valid UTF-8") 251 | .detail(format!("File at '{target_path:?}' has a non-UTF-8 name")) 252 | })?; 253 | 254 | // This approach writes the symlink target path as the content of the symlink entry. 255 | // We detect symlinks during extraction by checking for the Unix symlink mode (0o120000) in the entry's permissions. 256 | #[cfg(unix)] 257 | let symlink_options = options.unix_permissions(0o120000 | (mode & 0o777)); 258 | #[cfg(windows)] 259 | let symlink_options = options.unix_permissions(0o120777); 260 | 261 | writer.add_symlink(entry_name, target_name, symlink_options)?; 262 | } else { 263 | #[cfg(not(unix))] 264 | let options = if is_executable::is_executable(path) { 265 | executable 266 | } else { 267 | options 268 | }; 269 | 270 | let mut file = fs::File::open(path)?; 271 | 272 | #[cfg(unix)] 273 | let options = options.unix_permissions(mode); 274 | // Updated last modified time 275 | let last_modified_time = options.last_modified_time(get_last_modified_time(&file)); 276 | 277 | writer.start_file(entry_name, last_modified_time)?; 278 | io::copy(&mut file, &mut writer)?; 279 | } 280 | } 281 | 282 | env::set_current_dir(previous_location)?; 283 | } 284 | 285 | let bytes = writer.finish()?; 286 | Ok(bytes) 287 | } 288 | 289 | fn display_zip_comment_if_exists(file: &ZipFile) { 290 | let comment = file.comment(); 291 | if !comment.is_empty() { 292 | // Zip file comments seem to be pretty rare, but if they are used, 293 | // they may contain important information, so better show them 294 | // 295 | // "The .ZIP file format allows for a comment containing up to 65,535 (216−1) bytes 296 | // of data to occur at the end of the file after the central directory." 297 | // 298 | // If there happen to be cases of very long and unnecessary comments in 299 | // the future, maybe asking the user if he wants to display the comment 300 | // (informing him of its size) would be sensible for both normal and 301 | // accessibility mode.. 302 | info_accessible(format!("Found comment in {}: {}", file.name(), comment)); 303 | } 304 | } 305 | 306 | fn get_last_modified_time(file: &fs::File) -> DateTime { 307 | file.metadata() 308 | .and_then(|metadata| metadata.modified()) 309 | .ok() 310 | .and_then(|time| DateTime::try_from(OffsetDateTime::from(time)).ok()) 311 | .unwrap_or_default() 312 | } 313 | 314 | fn set_last_modified_time(zip_file: &ZipFile, path: &Path) -> crate::Result<()> { 315 | let modification_time = zip_file.last_modified().to_time(); 316 | 317 | let Ok(time_in_seconds) = modification_time else { 318 | return Ok(()); 319 | }; 320 | 321 | // Zip does not support nanoseconds, so we can assume zero here 322 | let modification_time = FileTime::from_unix_time(time_in_seconds.unix_timestamp(), 0); 323 | 324 | set_file_mtime(path, modification_time)?; 325 | 326 | Ok(()) 327 | } 328 | 329 | #[cfg(unix)] 330 | fn unix_set_permissions(file_path: &Path, file: &ZipFile) -> crate::Result<()> { 331 | use std::fs::Permissions; 332 | 333 | if let Some(mode) = file.unix_mode() { 334 | fs::set_permissions(file_path, Permissions::from_mode(mode))?; 335 | } 336 | 337 | Ok(()) 338 | } 339 | -------------------------------------------------------------------------------- /src/check.rs: -------------------------------------------------------------------------------- 1 | //! Checks for errors. 2 | 3 | #![warn(missing_docs)] 4 | 5 | use std::{ 6 | ffi::OsString, 7 | ops::ControlFlow, 8 | path::{Path, PathBuf}, 9 | }; 10 | 11 | use crate::{ 12 | error::FinalError, 13 | extension::{build_archive_file_suggestion, Extension}, 14 | utils::{ 15 | logger::{info_accessible, warning}, 16 | pretty_format_list_of_paths, try_infer_extension, user_wants_to_continue, EscapedPathDisplay, 17 | }, 18 | QuestionAction, QuestionPolicy, Result, 19 | }; 20 | 21 | /// Check if the mime type matches the detected extensions. 22 | /// 23 | /// In case the file doesn't has any extensions, try to infer the format. 24 | /// 25 | /// TODO: maybe the name of this should be "magic numbers" or "file signature", 26 | /// and not MIME. 27 | pub fn check_mime_type( 28 | path: &Path, 29 | formats: &mut Vec, 30 | question_policy: QuestionPolicy, 31 | ) -> Result> { 32 | if formats.is_empty() { 33 | // File with no extension 34 | // Try to detect it automatically and prompt the user about it 35 | if let Some(detected_format) = try_infer_extension(path) { 36 | // Inferring the file extension can have unpredicted consequences (e.g. the user just 37 | // mistyped, ...) which we should always inform the user about. 38 | warning(format!( 39 | "We detected a file named `{}`, do you want to decompress it?", 40 | path.display(), 41 | )); 42 | 43 | if user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? { 44 | formats.push(detected_format); 45 | } else { 46 | return Ok(ControlFlow::Break(())); 47 | } 48 | } 49 | } else if let Some(detected_format) = try_infer_extension(path) { 50 | // File ending with extension 51 | // Try to detect the extension and warn the user if it differs from the written one 52 | 53 | let outer_ext = formats.iter().next_back().unwrap(); 54 | if !outer_ext 55 | .compression_formats 56 | .ends_with(detected_format.compression_formats) 57 | { 58 | warning(format!( 59 | "The file extension: `{}` differ from the detected extension: `{}`", 60 | outer_ext, detected_format 61 | )); 62 | 63 | if !user_wants_to_continue(path, question_policy, QuestionAction::Decompression)? { 64 | return Ok(ControlFlow::Break(())); 65 | } 66 | } 67 | } else { 68 | // NOTE: If this actually produces no false positives, we can upgrade it in the future 69 | // to a warning and ask the user if he wants to continue decompressing. 70 | info_accessible(format!( 71 | "Failed to confirm the format of `{}` by sniffing the contents, file might be misnamed", 72 | path.display() 73 | )); 74 | } 75 | Ok(ControlFlow::Continue(())) 76 | } 77 | 78 | /// In the context of listing archives, this function checks if `ouch` was told to list 79 | /// the contents of a compressed file that is not an archive 80 | pub fn check_for_non_archive_formats(files: &[PathBuf], formats: &[Vec]) -> Result<()> { 81 | let mut not_archives = files 82 | .iter() 83 | .zip(formats) 84 | .filter(|(_, formats)| !formats.first().map(Extension::is_archive).unwrap_or(false)) 85 | .map(|(path, _)| path) 86 | .peekable(); 87 | 88 | if not_archives.peek().is_some() { 89 | let not_archives: Vec<_> = not_archives.collect(); 90 | let error = FinalError::with_title("Cannot list archive contents") 91 | .detail("Only archives can have their contents listed") 92 | .detail(format!( 93 | "Files are not archives: {}", 94 | pretty_format_list_of_paths(¬_archives) 95 | )); 96 | 97 | return Err(error.into()); 98 | } 99 | 100 | Ok(()) 101 | } 102 | 103 | /// Show error if archive format is not the first format in the chain. 104 | pub fn check_archive_formats_position(formats: &[Extension], output_path: &Path) -> Result<()> { 105 | if let Some(format) = formats.iter().skip(1).find(|format| format.is_archive()) { 106 | let error = FinalError::with_title(format!( 107 | "Cannot compress to '{}'.", 108 | EscapedPathDisplay::new(output_path) 109 | )) 110 | .detail(format!("Found the format '{format}' in an incorrect position.")) 111 | .detail(format!( 112 | "'{format}' can only be used at the start of the file extension." 113 | )) 114 | .hint(format!( 115 | "If you wish to compress multiple files, start the extension with '{format}'." 116 | )) 117 | .hint(format!( 118 | "Otherwise, remove the last '{}' from '{}'.", 119 | format, 120 | EscapedPathDisplay::new(output_path) 121 | )); 122 | 123 | return Err(error.into()); 124 | } 125 | Ok(()) 126 | } 127 | 128 | /// Check if all provided files have formats to decompress. 129 | pub fn check_missing_formats_when_decompressing(files: &[PathBuf], formats: &[Vec]) -> Result<()> { 130 | let files_with_broken_extension: Vec<&PathBuf> = files 131 | .iter() 132 | .zip(formats) 133 | .filter(|(_, format)| format.is_empty()) 134 | .map(|(input_path, _)| input_path) 135 | .collect(); 136 | 137 | if files_with_broken_extension.is_empty() { 138 | return Ok(()); 139 | } 140 | 141 | let (files_with_unsupported_extensions, files_missing_extension): (Vec<&PathBuf>, Vec<&PathBuf>) = 142 | files_with_broken_extension 143 | .iter() 144 | .partition(|path| path.extension().is_some()); 145 | 146 | let mut error = FinalError::with_title("Cannot decompress files"); 147 | 148 | if !files_with_unsupported_extensions.is_empty() { 149 | error = error.detail(format!( 150 | "Files with unsupported extensions: {}", 151 | pretty_format_list_of_paths(&files_with_unsupported_extensions) 152 | )); 153 | } 154 | 155 | if !files_missing_extension.is_empty() { 156 | error = error.detail(format!( 157 | "Files with missing extensions: {}", 158 | pretty_format_list_of_paths(&files_missing_extension) 159 | )); 160 | } 161 | 162 | error = error.detail("Decompression formats are detected automatically from file extension"); 163 | error = error.hint_all_supported_formats(); 164 | 165 | // If there's exactly one file, give a suggestion to use `--format` 166 | if let &[path] = files_with_broken_extension.as_slice() { 167 | error = error 168 | .hint("") 169 | .hint("Alternatively, you can pass an extension to the '--format' flag:") 170 | .hint(format!( 171 | " ouch decompress {} --format tar.gz", 172 | EscapedPathDisplay::new(path), 173 | )); 174 | } 175 | 176 | Err(error.into()) 177 | } 178 | 179 | /// Check if there is a first format when compressing, and returns it. 180 | pub fn check_first_format_when_compressing<'a>(formats: &'a [Extension], output_path: &Path) -> Result<&'a Extension> { 181 | formats.first().ok_or_else(|| { 182 | let output_path = EscapedPathDisplay::new(output_path); 183 | FinalError::with_title(format!("Cannot compress to '{output_path}'.")) 184 | .detail("You shall supply the compression format") 185 | .hint("Try adding supported extensions (see --help):") 186 | .hint(format!(" ouch compress ... {output_path}.tar.gz")) 187 | .hint(format!(" ouch compress ... {output_path}.zip")) 188 | .hint("") 189 | .hint("Alternatively, you can overwrite this option by using the '--format' flag:") 190 | .hint(format!(" ouch compress ... {output_path} --format tar.gz")) 191 | .into() 192 | }) 193 | } 194 | 195 | /// Check if compression is invalid because an archive format is necessary. 196 | /// 197 | /// Non-archive formats don't support multiple file compression or folder compression. 198 | pub fn check_invalid_compression_with_non_archive_format( 199 | formats: &[Extension], 200 | output_path: &Path, 201 | files: &[PathBuf], 202 | formats_from_flag: Option<&OsString>, 203 | ) -> Result<()> { 204 | let first_format = check_first_format_when_compressing(formats, output_path)?; 205 | 206 | let is_some_input_a_folder = files.iter().any(|path| path.is_dir()); 207 | let is_multiple_inputs = files.len() > 1; 208 | 209 | // If format is archive, nothing to check 210 | // If there's no folder or multiple inputs, non-archive formats can handle it 211 | if first_format.is_archive() || !is_some_input_a_folder && !is_multiple_inputs { 212 | return Ok(()); 213 | } 214 | 215 | let first_detail_message = if is_multiple_inputs { 216 | "You are trying to compress multiple files." 217 | } else { 218 | "You are trying to compress a folder." 219 | }; 220 | 221 | let (from_hint, to_hint) = if let Some(formats) = formats_from_flag { 222 | let formats = formats.to_string_lossy(); 223 | ( 224 | format!("From: --format {formats}"), 225 | format!("To: --format tar.{formats}"), 226 | ) 227 | } else { 228 | // This piece of code creates a suggestion for compressing multiple files 229 | // It says: 230 | // Change from file.bz.xz 231 | // To file.tar.bz.xz 232 | let suggested_output_path = build_archive_file_suggestion(output_path, ".tar") 233 | .expect("output path should contain a compression format"); 234 | 235 | ( 236 | format!("From: {}", EscapedPathDisplay::new(output_path)), 237 | format!("To: {suggested_output_path}"), 238 | ) 239 | }; 240 | let output_path = EscapedPathDisplay::new(output_path); 241 | 242 | let error = FinalError::with_title(format!("Cannot compress to '{output_path}'.")) 243 | .detail(first_detail_message) 244 | .detail(format!( 245 | "The compression format '{first_format}' does not accept multiple files.", 246 | )) 247 | .detail("Formats that bundle files into an archive are tar and zip.") 248 | .hint(format!("Try inserting 'tar.' or 'zip.' before '{first_format}'.")) 249 | .hint(from_hint) 250 | .hint(to_hint); 251 | 252 | Err(error.into()) 253 | } 254 | -------------------------------------------------------------------------------- /src/cli/args.rs: -------------------------------------------------------------------------------- 1 | use std::{ffi::OsString, path::PathBuf}; 2 | 3 | use clap::{Parser, ValueHint}; 4 | 5 | // Ouch command line options (docstrings below are part of --help) 6 | /// A command-line utility for easily compressing and decompressing files and directories. 7 | /// 8 | /// Supported formats: tar, zip, gz, 7z, xz/lzma, bz/bz2, bz3, lz4, sz (Snappy), zst, rar and br. 9 | /// 10 | /// Repository: https://github.com/ouch-org/ouch 11 | #[derive(Parser, Debug, PartialEq)] 12 | #[command(about, version)] 13 | // Disable rustdoc::bare_urls because rustdoc parses URLs differently than Clap 14 | #[allow(rustdoc::bare_urls)] 15 | pub struct CliArgs { 16 | /// Skip [Y/n] questions, default to yes 17 | #[arg(short, long, conflicts_with = "no", global = true)] 18 | pub yes: bool, 19 | 20 | /// Skip [Y/n] questions, default to no 21 | #[arg(short, long, global = true)] 22 | pub no: bool, 23 | 24 | /// Activate accessibility mode, reducing visual noise 25 | #[arg(short = 'A', long, env = "ACCESSIBLE", global = true)] 26 | pub accessible: bool, 27 | 28 | /// Ignore hidden files 29 | #[arg(short = 'H', long, global = true)] 30 | pub hidden: bool, 31 | 32 | /// Silence output 33 | #[arg(short = 'q', long, global = true)] 34 | pub quiet: bool, 35 | 36 | /// Ignore files matched by git's ignore files 37 | #[arg(short = 'g', long, global = true)] 38 | pub gitignore: bool, 39 | 40 | /// Specify the format of the archive 41 | #[arg(short, long, global = true)] 42 | pub format: Option, 43 | 44 | /// Decompress or list with password 45 | #[arg(short = 'p', long = "password", global = true)] 46 | pub password: Option, 47 | 48 | /// Concurrent working threads 49 | #[arg(short = 'c', long, global = true)] 50 | pub threads: Option, 51 | 52 | // Ouch and claps subcommands 53 | #[command(subcommand)] 54 | pub cmd: Subcommand, 55 | } 56 | 57 | #[derive(Parser, PartialEq, Eq, Debug)] 58 | #[allow(rustdoc::bare_urls)] 59 | pub enum Subcommand { 60 | /// Compress one or more files into one output file 61 | #[command(visible_alias = "c")] 62 | Compress { 63 | /// Files to be compressed 64 | #[arg(required = true, value_hint = ValueHint::FilePath)] 65 | files: Vec, 66 | 67 | /// The resulting file. Its extensions can be used to specify the compression formats 68 | #[arg(required = true, value_hint = ValueHint::FilePath)] 69 | output: PathBuf, 70 | 71 | /// Compression level, applied to all formats 72 | #[arg(short, long, group = "compression-level")] 73 | level: Option, 74 | 75 | /// Fastest compression level possible, 76 | /// conflicts with --level and --slow 77 | #[arg(long, group = "compression-level")] 78 | fast: bool, 79 | 80 | /// Slowest (and best) compression level possible, 81 | /// conflicts with --level and --fast 82 | #[arg(long, group = "compression-level")] 83 | slow: bool, 84 | 85 | /// Archive target files instead of storing symlinks (supported by `tar` and `zip`) 86 | #[arg(long, short = 'S')] 87 | follow_symlinks: bool, 88 | }, 89 | /// Decompresses one or more files, optionally into another folder 90 | #[command(visible_alias = "d")] 91 | Decompress { 92 | /// Files to be decompressed, or "-" for stdin 93 | #[arg(required = true, num_args = 1.., value_hint = ValueHint::FilePath)] 94 | files: Vec, 95 | 96 | /// Place results in a directory other than the current one 97 | #[arg(short = 'd', long = "dir", value_hint = ValueHint::FilePath)] 98 | output_dir: Option, 99 | 100 | /// Remove the source file after successful decompression 101 | #[arg(short = 'r', long)] 102 | remove: bool, 103 | 104 | /// Disable Smart Unpack 105 | #[arg(long)] 106 | no_smart_unpack: bool, 107 | }, 108 | /// List contents of an archive 109 | #[command(visible_aliases = ["l", "ls"])] 110 | List { 111 | /// Archives whose contents should be listed 112 | #[arg(required = true, num_args = 1.., value_hint = ValueHint::FilePath)] 113 | archives: Vec, 114 | 115 | /// Show archive contents as a tree 116 | #[arg(short, long)] 117 | tree: bool, 118 | }, 119 | } 120 | 121 | #[cfg(test)] 122 | mod tests { 123 | use super::*; 124 | 125 | fn args_splitter(input: &str) -> impl Iterator { 126 | input.split_whitespace() 127 | } 128 | 129 | fn to_paths(iter: impl IntoIterator) -> Vec { 130 | iter.into_iter().map(PathBuf::from).collect() 131 | } 132 | 133 | macro_rules! test { 134 | ($args:expr, $expected:expr) => { 135 | let result = match CliArgs::try_parse_from(args_splitter($args)) { 136 | Ok(result) => result, 137 | Err(err) => panic!( 138 | "CLI result is Err, expected Ok, input: '{}'.\nResult: '{err}'", 139 | $args 140 | ), 141 | }; 142 | assert_eq!(result, $expected, "CLI result mismatched, input: '{}'.", $args); 143 | }; 144 | } 145 | 146 | fn mock_cli_args() -> CliArgs { 147 | CliArgs { 148 | yes: false, 149 | no: false, 150 | accessible: false, 151 | hidden: false, 152 | quiet: false, 153 | gitignore: false, 154 | format: None, 155 | // This is usually replaced in assertion tests 156 | password: None, 157 | threads: None, 158 | cmd: Subcommand::Decompress { 159 | // Put a crazy value here so no test can assert it unintentionally 160 | files: vec!["\x00\x11\x22".into()], 161 | output_dir: None, 162 | remove: false, 163 | no_smart_unpack: false, 164 | }, 165 | } 166 | } 167 | 168 | #[test] 169 | fn test_clap_cli_ok() { 170 | test!( 171 | "ouch decompress file.tar.gz", 172 | CliArgs { 173 | cmd: Subcommand::Decompress { 174 | files: to_paths(["file.tar.gz"]), 175 | output_dir: None, 176 | remove: false, 177 | no_smart_unpack: false, 178 | }, 179 | ..mock_cli_args() 180 | } 181 | ); 182 | test!( 183 | "ouch d file.tar.gz", 184 | CliArgs { 185 | cmd: Subcommand::Decompress { 186 | files: to_paths(["file.tar.gz"]), 187 | output_dir: None, 188 | remove: false, 189 | no_smart_unpack: false, 190 | }, 191 | ..mock_cli_args() 192 | } 193 | ); 194 | test!( 195 | "ouch d a b c", 196 | CliArgs { 197 | cmd: Subcommand::Decompress { 198 | files: to_paths(["a", "b", "c"]), 199 | output_dir: None, 200 | remove: false, 201 | no_smart_unpack: false, 202 | }, 203 | ..mock_cli_args() 204 | } 205 | ); 206 | 207 | test!( 208 | "ouch compress file file.tar.gz", 209 | CliArgs { 210 | cmd: Subcommand::Compress { 211 | files: to_paths(["file"]), 212 | output: PathBuf::from("file.tar.gz"), 213 | level: None, 214 | fast: false, 215 | slow: false, 216 | follow_symlinks: false, 217 | }, 218 | ..mock_cli_args() 219 | } 220 | ); 221 | test!( 222 | "ouch compress a b c archive.tar.gz", 223 | CliArgs { 224 | cmd: Subcommand::Compress { 225 | files: to_paths(["a", "b", "c"]), 226 | output: PathBuf::from("archive.tar.gz"), 227 | level: None, 228 | fast: false, 229 | slow: false, 230 | follow_symlinks: false, 231 | }, 232 | ..mock_cli_args() 233 | } 234 | ); 235 | test!( 236 | "ouch compress a b c archive.tar.gz", 237 | CliArgs { 238 | cmd: Subcommand::Compress { 239 | files: to_paths(["a", "b", "c"]), 240 | output: PathBuf::from("archive.tar.gz"), 241 | level: None, 242 | fast: false, 243 | slow: false, 244 | follow_symlinks: false, 245 | }, 246 | ..mock_cli_args() 247 | } 248 | ); 249 | 250 | let inputs = [ 251 | "ouch compress a b c output --format tar.gz", 252 | // https://github.com/clap-rs/clap/issues/5115 253 | // "ouch compress a b c --format tar.gz output", 254 | // "ouch compress a b --format tar.gz c output", 255 | // "ouch compress a --format tar.gz b c output", 256 | "ouch compress --format tar.gz a b c output", 257 | "ouch --format tar.gz compress a b c output", 258 | ]; 259 | for input in inputs { 260 | test!( 261 | input, 262 | CliArgs { 263 | cmd: Subcommand::Compress { 264 | files: to_paths(["a", "b", "c"]), 265 | output: PathBuf::from("output"), 266 | level: None, 267 | fast: false, 268 | slow: false, 269 | follow_symlinks: false, 270 | }, 271 | format: Some("tar.gz".into()), 272 | ..mock_cli_args() 273 | } 274 | ); 275 | } 276 | } 277 | 278 | #[test] 279 | fn test_clap_cli_err() { 280 | assert!(CliArgs::try_parse_from(args_splitter("ouch c")).is_err()); 281 | assert!(CliArgs::try_parse_from(args_splitter("ouch c input")).is_err()); 282 | assert!(CliArgs::try_parse_from(args_splitter("ouch d")).is_err()); 283 | assert!(CliArgs::try_parse_from(args_splitter("ouch l")).is_err()); 284 | } 285 | } 286 | -------------------------------------------------------------------------------- /src/cli/mod.rs: -------------------------------------------------------------------------------- 1 | //! CLI related functions, uses the clap argparsing definitions from `args.rs`. 2 | 3 | mod args; 4 | 5 | use std::{ 6 | io, 7 | path::{Path, PathBuf}, 8 | }; 9 | 10 | use clap::Parser; 11 | use fs_err as fs; 12 | 13 | pub use self::args::{CliArgs, Subcommand}; 14 | use crate::{ 15 | accessible::set_accessible, 16 | utils::{is_path_stdin, FileVisibilityPolicy}, 17 | QuestionPolicy, 18 | }; 19 | 20 | impl CliArgs { 21 | /// A helper method that calls `clap::Parser::parse`. 22 | /// 23 | /// And: 24 | /// 1. Make paths absolute. 25 | /// 2. Checks the QuestionPolicy. 26 | pub fn parse_and_validate_args() -> crate::Result<(Self, QuestionPolicy, FileVisibilityPolicy)> { 27 | let mut args = Self::parse(); 28 | 29 | set_accessible(args.accessible); 30 | 31 | let (Subcommand::Compress { files, .. } 32 | | Subcommand::Decompress { files, .. } 33 | | Subcommand::List { archives: files, .. }) = &mut args.cmd; 34 | *files = canonicalize_files(files)?; 35 | 36 | let skip_questions_positively = match (args.yes, args.no) { 37 | (false, false) => QuestionPolicy::Ask, 38 | (true, false) => QuestionPolicy::AlwaysYes, 39 | (false, true) => QuestionPolicy::AlwaysNo, 40 | (true, true) => unreachable!(), 41 | }; 42 | 43 | let file_visibility_policy = FileVisibilityPolicy::new() 44 | .read_git_exclude(args.gitignore) 45 | .read_ignore(args.gitignore) 46 | .read_git_ignore(args.gitignore) 47 | .read_hidden(args.hidden); 48 | 49 | Ok((args, skip_questions_positively, file_visibility_policy)) 50 | } 51 | } 52 | 53 | fn canonicalize_files(files: &[impl AsRef]) -> io::Result> { 54 | files 55 | .iter() 56 | .map(|f| { 57 | if is_path_stdin(f.as_ref()) || f.as_ref().is_symlink() { 58 | Ok(f.as_ref().to_path_buf()) 59 | } else { 60 | fs::canonicalize(f) 61 | } 62 | }) 63 | .collect() 64 | } 65 | -------------------------------------------------------------------------------- /src/commands/compress.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, BufWriter, Cursor, Seek, Write}, 3 | path::{Path, PathBuf}, 4 | }; 5 | 6 | use fs_err as fs; 7 | 8 | use super::warn_user_about_loading_sevenz_in_memory; 9 | use crate::{ 10 | archive, 11 | commands::warn_user_about_loading_zip_in_memory, 12 | extension::{split_first_compression_format, CompressionFormat::*, Extension}, 13 | utils::{io::lock_and_flush_output_stdio, user_wants_to_continue, FileVisibilityPolicy}, 14 | QuestionAction, QuestionPolicy, BUFFER_CAPACITY, 15 | }; 16 | 17 | /// Compress files into `output_file`. 18 | /// 19 | /// # Arguments: 20 | /// - `files`: is the list of paths to be compressed: ["dir/file1.txt", "dir/file2.txt"] 21 | /// - `extensions`: is a list of compression formats for compressing, example: [Tar, Gz] (in compression order) 22 | /// - `output_file` is the resulting compressed file name, example: "archive.tar.gz" 23 | /// 24 | /// # Return value 25 | /// - Returns `Ok(true)` if compressed all files normally. 26 | /// - Returns `Ok(false)` if user opted to abort compression mid-way. 27 | #[allow(clippy::too_many_arguments)] 28 | pub fn compress_files( 29 | files: Vec, 30 | extensions: Vec, 31 | output_file: fs::File, 32 | output_path: &Path, 33 | quiet: bool, 34 | follow_symlinks: bool, 35 | question_policy: QuestionPolicy, 36 | file_visibility_policy: FileVisibilityPolicy, 37 | level: Option, 38 | ) -> crate::Result { 39 | // If the input files contain a directory, then the total size will be underestimated 40 | let file_writer = BufWriter::with_capacity(BUFFER_CAPACITY, output_file); 41 | 42 | let mut writer: Box = Box::new(file_writer); 43 | 44 | // Grab previous encoder and wrap it inside of a new one 45 | let chain_writer_encoder = |format: &_, encoder| -> crate::Result<_> { 46 | let encoder: Box = match format { 47 | Gzip => Box::new( 48 | // by default, ParCompress uses a default compression level of 3 49 | // instead of the regular default that flate2 uses 50 | gzp::par::compress::ParCompress::::builder() 51 | .compression_level( 52 | level.map_or_else(Default::default, |l| gzp::Compression::new((l as u32).clamp(0, 9))), 53 | ) 54 | .from_writer(encoder), 55 | ), 56 | Bzip => Box::new(bzip2::write::BzEncoder::new( 57 | encoder, 58 | level.map_or_else(Default::default, |l| bzip2::Compression::new((l as u32).clamp(1, 9))), 59 | )), 60 | Bzip3 => { 61 | #[cfg(not(feature = "bzip3"))] 62 | return Err(archive::bzip3_stub::no_support()); 63 | 64 | #[cfg(feature = "bzip3")] 65 | Box::new( 66 | // Use block size of 16 MiB 67 | bzip3::write::Bz3Encoder::new(encoder, 16 * 2_usize.pow(20))?, 68 | ) 69 | } 70 | Lz4 => Box::new(lz4_flex::frame::FrameEncoder::new(encoder).auto_finish()), 71 | Lzma => Box::new(xz2::write::XzEncoder::new( 72 | encoder, 73 | level.map_or(6, |l| (l as u32).clamp(0, 9)), 74 | )), 75 | Snappy => Box::new( 76 | gzp::par::compress::ParCompress::::builder() 77 | .compression_level(gzp::par::compress::Compression::new( 78 | level.map_or_else(Default::default, |l| (l as u32).clamp(0, 9)), 79 | )) 80 | .from_writer(encoder), 81 | ), 82 | Zstd => { 83 | let mut zstd_encoder = zstd::stream::write::Encoder::new( 84 | encoder, 85 | level.map_or(zstd::DEFAULT_COMPRESSION_LEVEL, |l| { 86 | (l as i32).clamp(zstd::zstd_safe::min_c_level(), zstd::zstd_safe::max_c_level()) 87 | }), 88 | )?; 89 | // Use all available PHYSICAL cores for compression 90 | zstd_encoder.multithread(num_cpus::get_physical() as u32)?; 91 | Box::new(zstd_encoder.auto_finish()) 92 | } 93 | Brotli => { 94 | let default_level = 11; // Same as brotli CLI, default to highest compression 95 | let level = level.unwrap_or(default_level).clamp(0, 11) as u32; 96 | let win_size = 22; // default to 2^22 = 4 MiB window size 97 | Box::new(brotli::CompressorWriter::new(encoder, BUFFER_CAPACITY, level, win_size)) 98 | } 99 | Tar | Zip | Rar | SevenZip => unreachable!(), 100 | }; 101 | Ok(encoder) 102 | }; 103 | 104 | let (first_format, formats) = split_first_compression_format(&extensions); 105 | 106 | for format in formats.iter().rev() { 107 | writer = chain_writer_encoder(format, writer)?; 108 | } 109 | 110 | match first_format { 111 | Gzip | Bzip | Bzip3 | Lz4 | Lzma | Snappy | Zstd | Brotli => { 112 | writer = chain_writer_encoder(&first_format, writer)?; 113 | let mut reader = fs::File::open(&files[0])?; 114 | 115 | io::copy(&mut reader, &mut writer)?; 116 | } 117 | Tar => { 118 | archive::tar::build_archive_from_paths( 119 | &files, 120 | output_path, 121 | &mut writer, 122 | file_visibility_policy, 123 | quiet, 124 | follow_symlinks, 125 | )?; 126 | writer.flush()?; 127 | } 128 | Zip => { 129 | if !formats.is_empty() { 130 | // Locking necessary to guarantee that warning and question 131 | // messages stay adjacent 132 | let _locks = lock_and_flush_output_stdio(); 133 | 134 | warn_user_about_loading_zip_in_memory(); 135 | if !user_wants_to_continue(output_path, question_policy, QuestionAction::Compression)? { 136 | return Ok(false); 137 | } 138 | } 139 | 140 | let mut vec_buffer = Cursor::new(vec![]); 141 | 142 | archive::zip::build_archive_from_paths( 143 | &files, 144 | output_path, 145 | &mut vec_buffer, 146 | file_visibility_policy, 147 | quiet, 148 | follow_symlinks, 149 | )?; 150 | vec_buffer.rewind()?; 151 | io::copy(&mut vec_buffer, &mut writer)?; 152 | } 153 | Rar => { 154 | #[cfg(feature = "unrar")] 155 | return Err(archive::rar::no_compression()); 156 | 157 | #[cfg(not(feature = "unrar"))] 158 | return Err(archive::rar_stub::no_support()); 159 | } 160 | SevenZip => { 161 | if !formats.is_empty() { 162 | // Locking necessary to guarantee that warning and question 163 | // messages stay adjacent 164 | let _locks = lock_and_flush_output_stdio(); 165 | 166 | warn_user_about_loading_sevenz_in_memory(); 167 | if !user_wants_to_continue(output_path, question_policy, QuestionAction::Compression)? { 168 | return Ok(false); 169 | } 170 | } 171 | 172 | let mut vec_buffer = Cursor::new(vec![]); 173 | archive::sevenz::compress_sevenz(&files, output_path, &mut vec_buffer, file_visibility_policy, quiet)?; 174 | vec_buffer.rewind()?; 175 | io::copy(&mut vec_buffer, &mut writer)?; 176 | } 177 | } 178 | 179 | Ok(true) 180 | } 181 | -------------------------------------------------------------------------------- /src/commands/list.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, BufReader, Read}, 3 | path::Path, 4 | }; 5 | 6 | use fs_err as fs; 7 | 8 | use crate::{ 9 | archive, 10 | commands::warn_user_about_loading_zip_in_memory, 11 | extension::CompressionFormat::{self, *}, 12 | list::{self, FileInArchive, ListOptions}, 13 | utils::{io::lock_and_flush_output_stdio, user_wants_to_continue}, 14 | QuestionAction, QuestionPolicy, BUFFER_CAPACITY, 15 | }; 16 | 17 | /// File at input_file_path is opened for reading, example: "archive.tar.gz" 18 | /// formats contains each format necessary for decompression, example: [Gz, Tar] (in decompression order) 19 | pub fn list_archive_contents( 20 | archive_path: &Path, 21 | formats: Vec, 22 | list_options: ListOptions, 23 | question_policy: QuestionPolicy, 24 | password: Option<&[u8]>, 25 | ) -> crate::Result<()> { 26 | let reader = fs::File::open(archive_path)?; 27 | 28 | // Zip archives are special, because they require io::Seek, so it requires it's logic separated 29 | // from decoder chaining. 30 | // 31 | // This is the only case where we can read and unpack it directly, without having to do 32 | // in-memory decompression/copying first. 33 | // 34 | // Any other Zip decompression done can take up the whole RAM and freeze ouch. 35 | if let &[Zip] = formats.as_slice() { 36 | let zip_archive = zip::ZipArchive::new(reader)?; 37 | let files = crate::archive::zip::list_archive(zip_archive, password); 38 | list::list_files(archive_path, files, list_options)?; 39 | return Ok(()); 40 | } 41 | 42 | // Will be used in decoder chaining 43 | let reader = BufReader::with_capacity(BUFFER_CAPACITY, reader); 44 | let mut reader: Box = Box::new(reader); 45 | 46 | // Grab previous decoder and wrap it inside of a new one 47 | let chain_reader_decoder = 48 | |format: CompressionFormat, decoder: Box| -> crate::Result> { 49 | let decoder: Box = match format { 50 | Gzip => Box::new(flate2::read::GzDecoder::new(decoder)), 51 | Bzip => Box::new(bzip2::read::BzDecoder::new(decoder)), 52 | Bzip3 => { 53 | #[cfg(not(feature = "bzip3"))] 54 | return Err(archive::bzip3_stub::no_support()); 55 | 56 | #[cfg(feature = "bzip3")] 57 | Box::new(bzip3::read::Bz3Decoder::new(decoder).unwrap()) 58 | } 59 | Lz4 => Box::new(lz4_flex::frame::FrameDecoder::new(decoder)), 60 | Lzma => Box::new(xz2::read::XzDecoder::new(decoder)), 61 | Snappy => Box::new(snap::read::FrameDecoder::new(decoder)), 62 | Zstd => Box::new(zstd::stream::Decoder::new(decoder)?), 63 | Brotli => Box::new(brotli::Decompressor::new(decoder, BUFFER_CAPACITY)), 64 | Tar | Zip | Rar | SevenZip => unreachable!("should be treated by caller"), 65 | }; 66 | Ok(decoder) 67 | }; 68 | 69 | let mut misplaced_archive_format = None; 70 | for &format in formats.iter().skip(1).rev() { 71 | if format.archive_format() { 72 | misplaced_archive_format = Some(format); 73 | break; 74 | } 75 | reader = chain_reader_decoder(format, reader)?; 76 | } 77 | 78 | let archive_format = misplaced_archive_format.unwrap_or(formats[0]); 79 | let files: Box>> = match archive_format { 80 | Tar => Box::new(crate::archive::tar::list_archive(tar::Archive::new(reader))), 81 | Zip => { 82 | if formats.len() > 1 { 83 | // Locking necessary to guarantee that warning and question 84 | // messages stay adjacent 85 | let _locks = lock_and_flush_output_stdio(); 86 | 87 | warn_user_about_loading_zip_in_memory(); 88 | if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? { 89 | return Ok(()); 90 | } 91 | } 92 | 93 | let mut vec = vec![]; 94 | io::copy(&mut reader, &mut vec)?; 95 | let zip_archive = zip::ZipArchive::new(io::Cursor::new(vec))?; 96 | 97 | Box::new(crate::archive::zip::list_archive(zip_archive, password)) 98 | } 99 | #[cfg(feature = "unrar")] 100 | Rar => { 101 | if formats.len() > 1 { 102 | let mut temp_file = tempfile::NamedTempFile::new()?; 103 | io::copy(&mut reader, &mut temp_file)?; 104 | Box::new(crate::archive::rar::list_archive(temp_file.path(), password)?) 105 | } else { 106 | Box::new(crate::archive::rar::list_archive(archive_path, password)?) 107 | } 108 | } 109 | #[cfg(not(feature = "unrar"))] 110 | Rar => { 111 | return Err(crate::archive::rar_stub::no_support()); 112 | } 113 | SevenZip => { 114 | if formats.len() > 1 { 115 | // Locking necessary to guarantee that warning and question 116 | // messages stay adjacent 117 | let _locks = lock_and_flush_output_stdio(); 118 | 119 | warn_user_about_loading_zip_in_memory(); 120 | if !user_wants_to_continue(archive_path, question_policy, QuestionAction::Decompression)? { 121 | return Ok(()); 122 | } 123 | } 124 | 125 | let mut vec = vec![]; 126 | io::copy(&mut reader, &mut vec)?; 127 | 128 | Box::new(archive::sevenz::list_archive(io::Cursor::new(vec), password)?) 129 | } 130 | Gzip | Bzip | Bzip3 | Lz4 | Lzma | Snappy | Zstd | Brotli => { 131 | unreachable!("Not an archive, should be validated before calling this function."); 132 | } 133 | }; 134 | 135 | list::list_files(archive_path, files, list_options) 136 | } 137 | -------------------------------------------------------------------------------- /src/commands/mod.rs: -------------------------------------------------------------------------------- 1 | //! Receive command from the cli and call the respective function for that command. 2 | 3 | mod compress; 4 | mod decompress; 5 | mod list; 6 | 7 | use std::{ops::ControlFlow, path::PathBuf}; 8 | 9 | use bstr::ByteSlice; 10 | use decompress::DecompressOptions; 11 | use rayon::prelude::{IndexedParallelIterator, IntoParallelRefIterator, ParallelIterator}; 12 | use utils::colors; 13 | 14 | use crate::{ 15 | check, 16 | cli::Subcommand, 17 | commands::{compress::compress_files, decompress::decompress_file, list::list_archive_contents}, 18 | error::{Error, FinalError}, 19 | extension::{self, parse_format_flag}, 20 | list::ListOptions, 21 | utils::{ 22 | self, colors::*, is_path_stdin, logger::info_accessible, path_to_str, EscapedPathDisplay, FileVisibilityPolicy, 23 | QuestionAction, 24 | }, 25 | CliArgs, QuestionPolicy, 26 | }; 27 | 28 | /// Warn the user that (de)compressing this .zip archive might freeze their system. 29 | fn warn_user_about_loading_zip_in_memory() { 30 | const ZIP_IN_MEMORY_LIMITATION_WARNING: &str = "\n \ 31 | The format '.zip' is limited by design and cannot be (de)compressed with encoding streams.\n \ 32 | When chaining '.zip' with other formats, all (de)compression needs to be done in-memory\n \ 33 | Careful, you might run out of RAM if the archive is too large!"; 34 | 35 | eprintln!("{}[WARNING]{}: {ZIP_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET); 36 | } 37 | 38 | /// Warn the user that (de)compressing this .7z archive might freeze their system. 39 | fn warn_user_about_loading_sevenz_in_memory() { 40 | const SEVENZ_IN_MEMORY_LIMITATION_WARNING: &str = "\n \ 41 | The format '.7z' is limited by design and cannot be (de)compressed with encoding streams.\n \ 42 | When chaining '.7z' with other formats, all (de)compression needs to be done in-memory\n \ 43 | Careful, you might run out of RAM if the archive is too large!"; 44 | 45 | eprintln!("{}[WARNING]{}: {SEVENZ_IN_MEMORY_LIMITATION_WARNING}", *ORANGE, *RESET); 46 | } 47 | 48 | /// This function checks what command needs to be run and performs A LOT of ahead-of-time checks 49 | /// to assume everything is OK. 50 | /// 51 | /// There are a lot of custom errors to give enough error description and explanation. 52 | pub fn run( 53 | args: CliArgs, 54 | question_policy: QuestionPolicy, 55 | file_visibility_policy: FileVisibilityPolicy, 56 | ) -> crate::Result<()> { 57 | if let Some(threads) = args.threads { 58 | rayon::ThreadPoolBuilder::new() 59 | .num_threads(threads) 60 | .build_global() 61 | .unwrap(); 62 | } 63 | 64 | match args.cmd { 65 | Subcommand::Compress { 66 | files, 67 | output: output_path, 68 | level, 69 | fast, 70 | slow, 71 | follow_symlinks, 72 | } => { 73 | // After cleaning, if there are no input files left, exit 74 | if files.is_empty() { 75 | return Err(FinalError::with_title("No files to compress").into()); 76 | } 77 | 78 | // Formats from path extension, like "file.tar.gz.xz" -> vec![Tar, Gzip, Lzma] 79 | let (formats_from_flag, formats) = match args.format { 80 | Some(formats) => { 81 | let parsed_formats = parse_format_flag(&formats)?; 82 | (Some(formats), parsed_formats) 83 | } 84 | None => (None, extension::extensions_from_path(&output_path)?), 85 | }; 86 | 87 | check::check_invalid_compression_with_non_archive_format( 88 | &formats, 89 | &output_path, 90 | &files, 91 | formats_from_flag.as_ref(), 92 | )?; 93 | check::check_archive_formats_position(&formats, &output_path)?; 94 | 95 | let output_file = 96 | match utils::ask_to_create_file(&output_path, question_policy, QuestionAction::Compression)? { 97 | Some(writer) => writer, 98 | None => return Ok(()), 99 | }; 100 | 101 | let level = if fast { 102 | Some(1) // Lowest level of compression 103 | } else if slow { 104 | Some(i16::MAX) // Highest level of compression 105 | } else { 106 | level 107 | }; 108 | 109 | let compress_result = compress_files( 110 | files, 111 | formats, 112 | output_file, 113 | &output_path, 114 | args.quiet, 115 | follow_symlinks, 116 | question_policy, 117 | file_visibility_policy, 118 | level, 119 | ); 120 | 121 | if let Ok(true) = compress_result { 122 | // this is only printed once, so it doesn't result in much text. On the other hand, 123 | // having a final status message is important especially in an accessibility context 124 | // as screen readers may not read a commands exit code, making it hard to reason 125 | // about whether the command succeeded without such a message 126 | info_accessible(format!("Successfully compressed '{}'", path_to_str(&output_path))); 127 | } else { 128 | // If Ok(false) or Err() occurred, delete incomplete file at `output_path` 129 | // 130 | // if deleting fails, print an extra alert message pointing 131 | // out that we left a possibly CORRUPTED file at `output_path` 132 | if utils::remove_file_or_dir(&output_path).is_err() { 133 | eprintln!("{red}FATAL ERROR:\n", red = *colors::RED); 134 | eprintln!( 135 | " Ouch failed to delete the file '{}'.", 136 | EscapedPathDisplay::new(&output_path) 137 | ); 138 | eprintln!(" Please delete it manually."); 139 | eprintln!(" This file is corrupted if compression didn't finished."); 140 | 141 | if compress_result.is_err() { 142 | eprintln!(" Compression failed for reasons below."); 143 | } 144 | } 145 | } 146 | 147 | compress_result.map(|_| ()) 148 | } 149 | Subcommand::Decompress { 150 | files, 151 | output_dir, 152 | remove, 153 | no_smart_unpack, 154 | } => { 155 | let mut output_paths = vec![]; 156 | let mut formats = vec![]; 157 | 158 | if let Some(format) = args.format { 159 | let format = parse_format_flag(&format)?; 160 | for path in files.iter() { 161 | // TODO: use Error::Custom 162 | let file_name = path.file_name().ok_or_else(|| Error::NotFound { 163 | error_title: format!("{} does not have a file name", EscapedPathDisplay::new(path)), 164 | })?; 165 | output_paths.push(file_name.as_ref()); 166 | formats.push(format.clone()); 167 | } 168 | } else { 169 | for path in files.iter() { 170 | let (pathbase, mut file_formats) = extension::separate_known_extensions_from_name(path)?; 171 | 172 | if let ControlFlow::Break(_) = check::check_mime_type(path, &mut file_formats, question_policy)? { 173 | return Ok(()); 174 | } 175 | 176 | output_paths.push(pathbase); 177 | formats.push(file_formats); 178 | } 179 | } 180 | 181 | check::check_missing_formats_when_decompressing(&files, &formats)?; 182 | 183 | let is_output_dir_provided = output_dir.is_some(); 184 | let is_smart_unpack = !is_output_dir_provided && !no_smart_unpack; 185 | 186 | // The directory that will contain the output files 187 | // We default to the current directory if the user didn't specify an output directory with --dir 188 | let output_dir = if let Some(dir) = output_dir { 189 | utils::create_dir_if_non_existent(&dir)?; 190 | dir 191 | } else { 192 | PathBuf::from(".") 193 | }; 194 | 195 | files 196 | .par_iter() 197 | .zip(formats) 198 | .zip(output_paths) 199 | .try_for_each(|((input_path, formats), file_name)| { 200 | // Path used by single file format archives 201 | let output_file_path = if is_path_stdin(file_name) { 202 | output_dir.join("stdin-output") 203 | } else { 204 | output_dir.join(file_name) 205 | }; 206 | decompress_file(DecompressOptions { 207 | input_file_path: input_path, 208 | formats, 209 | is_output_dir_provided, 210 | output_dir: &output_dir, 211 | output_file_path, 212 | is_smart_unpack, 213 | question_policy, 214 | quiet: args.quiet, 215 | password: args.password.as_deref().map(|str| { 216 | <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed") 217 | }), 218 | remove, 219 | }) 220 | }) 221 | } 222 | Subcommand::List { archives: files, tree } => { 223 | let mut formats = vec![]; 224 | 225 | if let Some(format) = args.format { 226 | let format = parse_format_flag(&format)?; 227 | for _ in 0..files.len() { 228 | formats.push(format.clone()); 229 | } 230 | } else { 231 | for path in files.iter() { 232 | let mut file_formats = extension::extensions_from_path(path)?; 233 | 234 | if let ControlFlow::Break(_) = check::check_mime_type(path, &mut file_formats, question_policy)? { 235 | return Ok(()); 236 | } 237 | 238 | formats.push(file_formats); 239 | } 240 | } 241 | 242 | // Ensure we were not told to list the content of a non-archive compressed file 243 | check::check_for_non_archive_formats(&files, &formats)?; 244 | 245 | let list_options = ListOptions { tree }; 246 | 247 | for (i, (archive_path, formats)) in files.iter().zip(formats).enumerate() { 248 | if i > 0 { 249 | println!(); 250 | } 251 | let formats = extension::flatten_compression_formats(&formats); 252 | list_archive_contents( 253 | archive_path, 254 | formats, 255 | list_options, 256 | question_policy, 257 | args.password 258 | .as_deref() 259 | .map(|str| <[u8] as ByteSlice>::from_os_str(str).expect("convert password to bytes failed")), 260 | )?; 261 | } 262 | 263 | Ok(()) 264 | } 265 | } 266 | } 267 | -------------------------------------------------------------------------------- /src/error.rs: -------------------------------------------------------------------------------- 1 | //! Error types definitions. 2 | //! 3 | //! All usage errors will pass through the Error enum, a lot of them in the Error::Custom. 4 | 5 | use std::{ 6 | borrow::Cow, 7 | ffi::OsString, 8 | fmt::{self, Display}, 9 | io, 10 | }; 11 | 12 | use crate::{ 13 | accessible::is_running_in_accessible_mode, 14 | extension::{PRETTY_SUPPORTED_ALIASES, PRETTY_SUPPORTED_EXTENSIONS}, 15 | utils::os_str_to_str, 16 | }; 17 | 18 | /// All errors that can be generated by `ouch` 19 | #[derive(Debug, Clone)] 20 | pub enum Error { 21 | /// An IoError that doesn't have a dedicated error variant 22 | IoError { reason: String }, 23 | /// From lzzzz::lz4f::Error 24 | Lz4Error { reason: String }, 25 | /// Detected from io::Error if .kind() is io::ErrorKind::NotFound 26 | NotFound { error_title: String }, 27 | /// NEEDS MORE CONTEXT 28 | AlreadyExists { error_title: String }, 29 | /// From zip::result::ZipError::InvalidArchive 30 | InvalidZipArchive(&'static str), 31 | /// Detected from io::Error if .kind() is io::ErrorKind::PermissionDenied 32 | PermissionDenied { error_title: String }, 33 | /// From zip::result::ZipError::UnsupportedArchive 34 | UnsupportedZipArchive(&'static str), 35 | /// We don't support compressing the root folder. 36 | CompressingRootFolder, 37 | /// Specialized walkdir's io::Error wrapper with additional information on the error 38 | WalkdirError { reason: String }, 39 | /// Custom and unique errors are reported in this variant 40 | Custom { reason: FinalError }, 41 | /// Invalid format passed to `--format` 42 | InvalidFormatFlag { text: OsString, reason: String }, 43 | /// From sevenz_rust::Error 44 | SevenzipError { reason: String }, 45 | /// Recognised but unsupported format 46 | // currently only RAR when built without the `unrar` feature 47 | UnsupportedFormat { reason: String }, 48 | /// Invalid password provided 49 | InvalidPassword { reason: String }, 50 | } 51 | 52 | /// Alias to std's Result with ouch's Error 53 | pub type Result = std::result::Result; 54 | 55 | /// A string either heap-allocated or located in static storage 56 | pub type CowStr = Cow<'static, str>; 57 | 58 | /// Pretty final error message for end users, crashing the program after display. 59 | #[derive(Clone, Debug, Default, PartialEq, Eq)] 60 | pub struct FinalError { 61 | /// Should be made of just one line, appears after the "\[ERROR\]" part 62 | title: CowStr, 63 | /// Shown as a unnumbered list in yellow 64 | details: Vec, 65 | /// Shown as green at the end to give hints on how to work around this error, if it's fixable 66 | hints: Vec, 67 | } 68 | 69 | impl Display for FinalError { 70 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 71 | use crate::utils::colors::*; 72 | 73 | // Title 74 | // 75 | // When in ACCESSIBLE mode, the square brackets are suppressed 76 | if is_running_in_accessible_mode() { 77 | write!(f, "{}ERROR{}: {}", *RED, *RESET, self.title)?; 78 | } else { 79 | write!(f, "{}[ERROR]{} {}", *RED, *RESET, self.title)?; 80 | } 81 | 82 | // Details 83 | for detail in &self.details { 84 | write!(f, "\n - {}{}{}", *YELLOW, detail, *RESET)?; 85 | } 86 | 87 | // Hints 88 | if !self.hints.is_empty() { 89 | // Separate by one blank line. 90 | writeln!(f)?; 91 | // to reduce redundant output for text-to-speech systems, braille 92 | // displays and so on, only print "hints" once in ACCESSIBLE mode 93 | if is_running_in_accessible_mode() { 94 | write!(f, "\n{}hints:{}", *GREEN, *RESET)?; 95 | for hint in &self.hints { 96 | write!(f, "\n{hint}")?; 97 | } 98 | } else { 99 | for hint in &self.hints { 100 | write!(f, "\n{}hint:{} {}", *GREEN, *RESET, hint)?; 101 | } 102 | } 103 | } 104 | 105 | Ok(()) 106 | } 107 | } 108 | 109 | impl FinalError { 110 | /// Only constructor 111 | #[must_use] 112 | pub fn with_title(title: impl Into) -> Self { 113 | Self { 114 | title: title.into(), 115 | details: vec![], 116 | hints: vec![], 117 | } 118 | } 119 | 120 | /// Add one detail line, can have multiple 121 | #[must_use] 122 | pub fn detail(mut self, detail: impl Into) -> Self { 123 | self.details.push(detail.into()); 124 | self 125 | } 126 | 127 | /// Add one hint line, can have multiple 128 | #[must_use] 129 | pub fn hint(mut self, hint: impl Into) -> Self { 130 | self.hints.push(hint.into()); 131 | self 132 | } 133 | 134 | /// Adds all supported formats as hints. 135 | /// 136 | /// This is what it looks like: 137 | /// ``` 138 | /// hint: Supported extensions are: tar, zip, bz, bz2, gz, lz4, xz, lzma, sz, zst 139 | /// hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 140 | /// ``` 141 | pub fn hint_all_supported_formats(self) -> Self { 142 | self.hint(format!("Supported extensions are: {}", PRETTY_SUPPORTED_EXTENSIONS)) 143 | .hint(format!("Supported aliases are: {}", PRETTY_SUPPORTED_ALIASES)) 144 | } 145 | } 146 | 147 | impl From for FinalError { 148 | fn from(err: Error) -> Self { 149 | match err { 150 | Error::WalkdirError { reason } => FinalError::with_title(reason), 151 | Error::NotFound { error_title } => FinalError::with_title(error_title).detail("File not found"), 152 | Error::CompressingRootFolder => { 153 | FinalError::with_title("It seems you're trying to compress the root folder.") 154 | .detail("This is unadvisable since ouch does compressions in-memory.") 155 | .hint("Use a more appropriate tool for this, such as rsync.") 156 | } 157 | Error::IoError { reason } => FinalError::with_title(reason), 158 | Error::Lz4Error { reason } => FinalError::with_title(reason), 159 | Error::AlreadyExists { error_title } => FinalError::with_title(error_title).detail("File already exists"), 160 | Error::InvalidZipArchive(reason) => FinalError::with_title("Invalid zip archive").detail(reason), 161 | Error::PermissionDenied { error_title } => FinalError::with_title(error_title).detail("Permission denied"), 162 | Error::UnsupportedZipArchive(reason) => FinalError::with_title("Unsupported zip archive").detail(reason), 163 | Error::InvalidFormatFlag { reason, text } => { 164 | FinalError::with_title(format!("Failed to parse `--format {}`", os_str_to_str(&text))) 165 | .detail(reason) 166 | .hint_all_supported_formats() 167 | .hint("") 168 | .hint("Examples:") 169 | .hint(" --format tar") 170 | .hint(" --format gz") 171 | .hint(" --format tar.gz") 172 | } 173 | Error::Custom { reason } => reason.clone(), 174 | Error::SevenzipError { reason } => FinalError::with_title("7z error").detail(reason), 175 | Error::UnsupportedFormat { reason } => { 176 | FinalError::with_title("Recognised but unsupported format").detail(reason.clone()) 177 | } 178 | Error::InvalidPassword { reason } => FinalError::with_title("Invalid password").detail(reason.clone()), 179 | } 180 | } 181 | } 182 | 183 | impl fmt::Display for Error { 184 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 185 | let err = FinalError::from(self.clone()); 186 | write!(f, "{err}") 187 | } 188 | } 189 | 190 | impl From for Error { 191 | fn from(err: std::io::Error) -> Self { 192 | let error_title = err.to_string(); 193 | 194 | match err.kind() { 195 | io::ErrorKind::NotFound => Self::NotFound { error_title }, 196 | io::ErrorKind::PermissionDenied => Self::PermissionDenied { error_title }, 197 | io::ErrorKind::AlreadyExists => Self::AlreadyExists { error_title }, 198 | _other => Self::IoError { reason: error_title }, 199 | } 200 | } 201 | } 202 | 203 | #[cfg(feature = "bzip3")] 204 | impl From for Error { 205 | fn from(err: bzip3::Error) -> Self { 206 | use bzip3::Error as Bz3Error; 207 | match err { 208 | Bz3Error::Io(inner) => inner.into(), 209 | Bz3Error::BlockSize | Bz3Error::ProcessBlock(_) | Bz3Error::InvalidSignature => { 210 | FinalError::with_title("bzip3 error").detail(err.to_string()).into() 211 | } 212 | } 213 | } 214 | } 215 | 216 | impl From for Error { 217 | fn from(err: zip::result::ZipError) -> Self { 218 | use zip::result::ZipError; 219 | match err { 220 | ZipError::Io(io_err) => Self::from(io_err), 221 | ZipError::InvalidArchive(filename) => Self::InvalidZipArchive(filename), 222 | ZipError::FileNotFound => Self::Custom { 223 | reason: FinalError::with_title("Unexpected error in zip archive").detail("File not found"), 224 | }, 225 | ZipError::UnsupportedArchive(filename) => Self::UnsupportedZipArchive(filename), 226 | } 227 | } 228 | } 229 | 230 | #[cfg(feature = "unrar")] 231 | impl From for Error { 232 | fn from(err: unrar::error::UnrarError) -> Self { 233 | Self::Custom { 234 | reason: FinalError::with_title("Unexpected error in rar archive").detail(format!("{:?}", err.code)), 235 | } 236 | } 237 | } 238 | 239 | impl From for Error { 240 | fn from(err: sevenz_rust2::Error) -> Self { 241 | Self::SevenzipError { 242 | reason: err.to_string(), 243 | } 244 | } 245 | } 246 | 247 | impl From for Error { 248 | fn from(err: ignore::Error) -> Self { 249 | Self::WalkdirError { 250 | reason: err.to_string(), 251 | } 252 | } 253 | } 254 | 255 | impl From for Error { 256 | fn from(err: FinalError) -> Self { 257 | Self::Custom { reason: err } 258 | } 259 | } 260 | -------------------------------------------------------------------------------- /src/extension.rs: -------------------------------------------------------------------------------- 1 | //! Our representation of all the supported compression formats. 2 | 3 | use std::{ffi::OsStr, fmt, path::Path}; 4 | 5 | use bstr::ByteSlice; 6 | use CompressionFormat::*; 7 | 8 | use crate::{ 9 | error::{Error, FinalError, Result}, 10 | utils::logger::warning, 11 | }; 12 | 13 | pub const SUPPORTED_EXTENSIONS: &[&str] = &[ 14 | "tar", 15 | "zip", 16 | "bz", 17 | "bz2", 18 | "gz", 19 | "lz4", 20 | "xz", 21 | "lzma", 22 | "sz", 23 | "zst", 24 | #[cfg(feature = "unrar")] 25 | "rar", 26 | "7z", 27 | "br", 28 | ]; 29 | 30 | pub const SUPPORTED_ALIASES: &[&str] = &["tgz", "tbz", "tlz4", "txz", "tzlma", "tsz", "tzst"]; 31 | 32 | #[cfg(not(feature = "unrar"))] 33 | pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z"; 34 | #[cfg(feature = "unrar")] 35 | pub const PRETTY_SUPPORTED_EXTENSIONS: &str = "tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z"; 36 | 37 | pub const PRETTY_SUPPORTED_ALIASES: &str = "tgz, tbz, tlz4, txz, tzlma, tsz, tzst"; 38 | 39 | /// A wrapper around `CompressionFormat` that allows combinations like `tgz` 40 | #[derive(Debug, Clone)] 41 | // Keep `PartialEq` only for testing because two formats are the same even if 42 | // their `display_text` does not match (beware of aliases) 43 | #[cfg_attr(test, derive(PartialEq))] 44 | // Should only be built with constructors 45 | #[non_exhaustive] 46 | pub struct Extension { 47 | /// One extension like "tgz" can be made of multiple CompressionFormats ([Tar, Gz]) 48 | pub compression_formats: &'static [CompressionFormat], 49 | /// The input text for this extension, like "tgz", "tar" or "xz" 50 | display_text: String, 51 | } 52 | 53 | impl Extension { 54 | /// # Panics: 55 | /// Will panic if `formats` is empty 56 | pub fn new(formats: &'static [CompressionFormat], text: impl ToString) -> Self { 57 | assert!(!formats.is_empty()); 58 | Self { 59 | compression_formats: formats, 60 | display_text: text.to_string(), 61 | } 62 | } 63 | 64 | /// Checks if the first format in `compression_formats` is an archive 65 | pub fn is_archive(&self) -> bool { 66 | // Index Safety: we check that `compression_formats` is not empty in `Self::new` 67 | self.compression_formats[0].archive_format() 68 | } 69 | } 70 | 71 | impl fmt::Display for Extension { 72 | fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { 73 | self.display_text.fmt(f) 74 | } 75 | } 76 | 77 | #[derive(Copy, Clone, PartialEq, Eq, Debug)] 78 | /// Accepted extensions for input and output 79 | pub enum CompressionFormat { 80 | /// .gz 81 | Gzip, 82 | /// .bz .bz2 83 | Bzip, 84 | /// .bz3 85 | Bzip3, 86 | /// .lz4 87 | Lz4, 88 | /// .xz .lzma 89 | Lzma, 90 | /// .sz 91 | Snappy, 92 | /// tar, tgz, tbz, tbz2, tbz3, txz, tlz4, tlzma, tsz, tzst 93 | Tar, 94 | /// .zst 95 | Zstd, 96 | /// .zip 97 | Zip, 98 | // even if built without RAR support, we still want to recognise the format 99 | /// .rar 100 | Rar, 101 | /// .7z 102 | SevenZip, 103 | /// .br 104 | Brotli, 105 | } 106 | 107 | impl CompressionFormat { 108 | /// Currently supported archive formats are .tar (and aliases to it) and .zip 109 | pub fn archive_format(&self) -> bool { 110 | // Keep this match like that without a wildcard `_` so we don't forget to update it 111 | match self { 112 | Tar | Zip | Rar | SevenZip => true, 113 | Gzip => false, 114 | Bzip => false, 115 | Bzip3 => false, 116 | Lz4 => false, 117 | Lzma => false, 118 | Snappy => false, 119 | Zstd => false, 120 | Brotli => false, 121 | } 122 | } 123 | } 124 | 125 | fn to_extension(ext: &[u8]) -> Option { 126 | Some(Extension::new( 127 | match ext { 128 | b"tar" => &[Tar], 129 | b"tgz" => &[Tar, Gzip], 130 | b"tbz" | b"tbz2" => &[Tar, Bzip], 131 | b"tbz3" => &[Tar, Bzip3], 132 | b"tlz4" => &[Tar, Lz4], 133 | b"txz" | b"tlzma" => &[Tar, Lzma], 134 | b"tsz" => &[Tar, Snappy], 135 | b"tzst" => &[Tar, Zstd], 136 | b"zip" => &[Zip], 137 | b"bz" | b"bz2" => &[Bzip], 138 | b"bz3" => &[Bzip3], 139 | b"gz" => &[Gzip], 140 | b"lz4" => &[Lz4], 141 | b"xz" | b"lzma" => &[Lzma], 142 | b"sz" => &[Snappy], 143 | b"zst" => &[Zstd], 144 | b"rar" => &[Rar], 145 | b"7z" => &[SevenZip], 146 | b"br" => &[Brotli], 147 | _ => return None, 148 | }, 149 | ext.to_str_lossy(), 150 | )) 151 | } 152 | 153 | fn split_extension_at_end(name: &[u8]) -> Option<(&[u8], Extension)> { 154 | let (new_name, ext) = name.rsplit_once_str(b".")?; 155 | if matches!(new_name, b"" | b"." | b"..") { 156 | return None; 157 | } 158 | let ext = to_extension(ext)?; 159 | Some((new_name, ext)) 160 | } 161 | 162 | pub fn parse_format_flag(input: &OsStr) -> crate::Result> { 163 | let format = input.as_encoded_bytes(); 164 | 165 | let format = std::str::from_utf8(format).map_err(|_| Error::InvalidFormatFlag { 166 | text: input.to_owned(), 167 | reason: "Invalid UTF-8.".to_string(), 168 | })?; 169 | 170 | let extensions: Vec = format 171 | .split('.') 172 | .filter(|extension| !extension.is_empty()) 173 | .map(|extension| { 174 | to_extension(extension.as_bytes()).ok_or_else(|| Error::InvalidFormatFlag { 175 | text: input.to_owned(), 176 | reason: format!("Unsupported extension '{}'", extension), 177 | }) 178 | }) 179 | .collect::>()?; 180 | 181 | if extensions.is_empty() { 182 | return Err(Error::InvalidFormatFlag { 183 | text: input.to_owned(), 184 | reason: "Parsing got an empty list of extensions.".to_string(), 185 | }); 186 | } 187 | 188 | Ok(extensions) 189 | } 190 | 191 | /// Extracts extensions from a path. 192 | /// 193 | /// Returns both the remaining path and the list of extension objects. 194 | pub fn separate_known_extensions_from_name(path: &Path) -> Result<(&Path, Vec)> { 195 | let mut extensions = vec![]; 196 | 197 | let Some(mut name) = path.file_name().and_then(<[u8] as ByteSlice>::from_os_str) else { 198 | return Ok((path, extensions)); 199 | }; 200 | 201 | while let Some((new_name, extension)) = split_extension_at_end(name) { 202 | name = new_name; 203 | extensions.insert(0, extension); 204 | if extensions[0].is_archive() { 205 | if let Some((_, misplaced_extension)) = split_extension_at_end(name) { 206 | let mut error = FinalError::with_title("File extensions are invalid for operation").detail(format!( 207 | "The archive extension '.{}' can only be placed at the start of the extension list", 208 | extensions[0].display_text, 209 | )); 210 | 211 | if misplaced_extension.compression_formats == extensions[0].compression_formats { 212 | error = error.detail(format!( 213 | "File: '{path:?}' contains '.{}' and '.{}'", 214 | misplaced_extension.display_text, extensions[0].display_text, 215 | )); 216 | } 217 | 218 | return Err(error 219 | .hint("You can use `--format` to specify what format to use, examples:") 220 | .hint(" ouch compress file.zip.zip file --format zip") 221 | .hint(" ouch decompress file --format zst") 222 | .hint(" ouch list archive --format tar.gz") 223 | .into()); 224 | } 225 | break; 226 | } 227 | } 228 | 229 | if let Ok(name) = name.to_str() { 230 | let file_stem = name.trim_matches('.'); 231 | if SUPPORTED_EXTENSIONS.contains(&file_stem) || SUPPORTED_ALIASES.contains(&file_stem) { 232 | warning(format!( 233 | "Received a file with name '{file_stem}', but {file_stem} was expected as the extension" 234 | )); 235 | } 236 | } 237 | 238 | Ok((name.to_path().unwrap(), extensions)) 239 | } 240 | 241 | /// Extracts extensions from a path, return only the list of extension objects 242 | pub fn extensions_from_path(path: &Path) -> Result> { 243 | separate_known_extensions_from_name(path).map(|(_, extensions)| extensions) 244 | } 245 | 246 | /// Panics if formats has an empty list of compression formats 247 | pub fn split_first_compression_format(formats: &[Extension]) -> (CompressionFormat, Vec) { 248 | let mut extensions: Vec = flatten_compression_formats(formats); 249 | let first_extension = extensions.remove(0); 250 | (first_extension, extensions) 251 | } 252 | 253 | pub fn flatten_compression_formats(extensions: &[Extension]) -> Vec { 254 | extensions 255 | .iter() 256 | .flat_map(|extension| extension.compression_formats.iter()) 257 | .copied() 258 | .collect() 259 | } 260 | 261 | /// Builds a suggested output file in scenarios where the user tried to compress 262 | /// a folder into a non-archive compression format, for error message purposes 263 | /// 264 | /// E.g.: `build_suggestion("file.bz.xz", ".tar")` results in `Some("file.tar.bz.xz")` 265 | pub fn build_archive_file_suggestion(path: &Path, suggested_extension: &str) -> Option { 266 | let path = path.to_string_lossy(); 267 | let mut rest = &*path; 268 | let mut position_to_insert = 0; 269 | 270 | // Walk through the path to find the first supported compression extension 271 | while let Some(pos) = rest.find('.') { 272 | // Use just the text located after the dot we found 273 | rest = &rest[pos + 1..]; 274 | position_to_insert += pos + 1; 275 | 276 | // If the string contains more chained extensions, clip to the immediate one 277 | let maybe_extension = { 278 | let idx = rest.find('.').unwrap_or(rest.len()); 279 | &rest[..idx] 280 | }; 281 | 282 | // If the extension we got is a supported extension, generate the suggestion 283 | // at the position we found 284 | if SUPPORTED_EXTENSIONS.contains(&maybe_extension) || SUPPORTED_ALIASES.contains(&maybe_extension) { 285 | let mut path = path.to_string(); 286 | path.insert_str(position_to_insert - 1, suggested_extension); 287 | 288 | return Some(path); 289 | } 290 | } 291 | 292 | None 293 | } 294 | 295 | #[cfg(test)] 296 | mod tests { 297 | use super::*; 298 | 299 | #[test] 300 | fn test_extensions_from_path() { 301 | let path = Path::new("bolovo.tar.gz"); 302 | 303 | let extensions = extensions_from_path(path).unwrap(); 304 | let formats = flatten_compression_formats(&extensions); 305 | 306 | assert_eq!(formats, vec![Tar, Gzip]); 307 | } 308 | 309 | #[test] 310 | /// Test extension parsing for input/output files 311 | fn test_separate_known_extensions_from_name() { 312 | assert_eq!( 313 | separate_known_extensions_from_name("file".as_ref()).unwrap(), 314 | ("file".as_ref(), vec![]) 315 | ); 316 | assert_eq!( 317 | separate_known_extensions_from_name("tar".as_ref()).unwrap(), 318 | ("tar".as_ref(), vec![]) 319 | ); 320 | assert_eq!( 321 | separate_known_extensions_from_name(".tar".as_ref()).unwrap(), 322 | (".tar".as_ref(), vec![]) 323 | ); 324 | assert_eq!( 325 | separate_known_extensions_from_name("file.tar".as_ref()).unwrap(), 326 | ("file".as_ref(), vec![Extension::new(&[Tar], "tar")]) 327 | ); 328 | assert_eq!( 329 | separate_known_extensions_from_name("file.tar.gz".as_ref()).unwrap(), 330 | ( 331 | "file".as_ref(), 332 | vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")] 333 | ) 334 | ); 335 | assert_eq!( 336 | separate_known_extensions_from_name(".tar.gz".as_ref()).unwrap(), 337 | (".tar".as_ref(), vec![Extension::new(&[Gzip], "gz")]) 338 | ); 339 | } 340 | 341 | #[test] 342 | /// Test extension parsing of `--format FORMAT` 343 | fn test_parse_of_format_flag() { 344 | assert_eq!( 345 | parse_format_flag(OsStr::new("tar")).unwrap(), 346 | vec![Extension::new(&[Tar], "tar")] 347 | ); 348 | assert_eq!( 349 | parse_format_flag(OsStr::new(".tar")).unwrap(), 350 | vec![Extension::new(&[Tar], "tar")] 351 | ); 352 | assert_eq!( 353 | parse_format_flag(OsStr::new("tar.gz")).unwrap(), 354 | vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")] 355 | ); 356 | assert_eq!( 357 | parse_format_flag(OsStr::new(".tar.gz")).unwrap(), 358 | vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")] 359 | ); 360 | assert_eq!( 361 | parse_format_flag(OsStr::new("..tar..gz.....")).unwrap(), 362 | vec![Extension::new(&[Tar], "tar"), Extension::new(&[Gzip], "gz")] 363 | ); 364 | 365 | assert!(parse_format_flag(OsStr::new("../tar.gz")).is_err()); 366 | assert!(parse_format_flag(OsStr::new("targz")).is_err()); 367 | assert!(parse_format_flag(OsStr::new("tar.gz.unknown")).is_err()); 368 | assert!(parse_format_flag(OsStr::new(".tar.gz.unknown")).is_err()); 369 | assert!(parse_format_flag(OsStr::new(".tar.!@#.gz")).is_err()); 370 | } 371 | 372 | #[test] 373 | fn builds_suggestion_correctly() { 374 | assert_eq!(build_archive_file_suggestion(Path::new("linux.png"), ".tar"), None); 375 | assert_eq!( 376 | build_archive_file_suggestion(Path::new("linux.xz.gz.zst"), ".tar").unwrap(), 377 | "linux.tar.xz.gz.zst" 378 | ); 379 | assert_eq!( 380 | build_archive_file_suggestion(Path::new("linux.pkg.xz.gz.zst"), ".tar").unwrap(), 381 | "linux.pkg.tar.xz.gz.zst" 382 | ); 383 | assert_eq!( 384 | build_archive_file_suggestion(Path::new("linux.pkg.zst"), ".tar").unwrap(), 385 | "linux.pkg.tar.zst" 386 | ); 387 | assert_eq!( 388 | build_archive_file_suggestion(Path::new("linux.pkg.info.zst"), ".tar").unwrap(), 389 | "linux.pkg.info.tar.zst" 390 | ); 391 | } 392 | 393 | #[test] 394 | fn test_extension_parsing_with_multiple_archive_formats() { 395 | assert!(separate_known_extensions_from_name("file.tar.zip".as_ref()).is_err()); 396 | assert!(separate_known_extensions_from_name("file.7z.zst.zip.lz4".as_ref()).is_err()); 397 | } 398 | } 399 | -------------------------------------------------------------------------------- /src/list.rs: -------------------------------------------------------------------------------- 1 | //! Some implementation helpers related to the 'list' command. 2 | 3 | use std::{ 4 | io::{stdout, BufWriter, Write}, 5 | path::{Path, PathBuf}, 6 | }; 7 | 8 | use self::tree::Tree; 9 | use crate::{accessible::is_running_in_accessible_mode, utils::EscapedPathDisplay}; 10 | 11 | /// Options controlling how archive contents should be listed 12 | #[derive(Debug, Clone, Copy)] 13 | pub struct ListOptions { 14 | /// Whether to show a tree view 15 | pub tree: bool, 16 | } 17 | 18 | /// Represents a single file in an archive, used in `list::list_files()` 19 | #[derive(Debug, Clone)] 20 | pub struct FileInArchive { 21 | /// The file path 22 | pub path: PathBuf, 23 | 24 | /// Whether this file is a directory 25 | pub is_dir: bool, 26 | } 27 | 28 | /// Actually print the files 29 | /// Returns an Error, if one of the files can't be read 30 | pub fn list_files( 31 | archive: &Path, 32 | files: impl IntoIterator>, 33 | list_options: ListOptions, 34 | ) -> crate::Result<()> { 35 | let mut out = BufWriter::new(stdout().lock()); 36 | let _ = writeln!(out, "Archive: {}", EscapedPathDisplay::new(archive)); 37 | 38 | if list_options.tree { 39 | let tree = files.into_iter().collect::>()?; 40 | tree.print(&mut out); 41 | } else { 42 | for file in files { 43 | let FileInArchive { path, is_dir } = file?; 44 | print_entry(&mut out, EscapedPathDisplay::new(&path), is_dir); 45 | } 46 | } 47 | Ok(()) 48 | } 49 | 50 | /// Print an entry and highlight directories, either by coloring them 51 | /// if that's supported or by adding a trailing / 52 | fn print_entry(out: &mut impl Write, name: impl std::fmt::Display, is_dir: bool) { 53 | use crate::utils::colors::*; 54 | 55 | if is_dir { 56 | // if colors are deactivated, print final / to mark directories 57 | if BLUE.is_empty() { 58 | let _ = writeln!(out, "{name}/"); 59 | // if in ACCESSIBLE mode, use colors but print final / in case colors 60 | // aren't read out aloud with a screen reader or aren't printed on a 61 | // braille reader 62 | } else if is_running_in_accessible_mode() { 63 | let _ = writeln!(out, "{}{}{}/{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET); 64 | } else { 65 | let _ = writeln!(out, "{}{}{}{}", *BLUE, *STYLE_BOLD, name, *ALL_RESET); 66 | } 67 | } else { 68 | // not a dir -> just print the file name 69 | let _ = writeln!(out, "{name}"); 70 | } 71 | } 72 | 73 | /// Since archives store files as a list of entries -> without direct 74 | /// directory structure (the directories are however part of the name), 75 | /// we have to construct the tree structure ourselves to be able to 76 | /// display them as a tree 77 | mod tree { 78 | use std::{ 79 | ffi::{OsStr, OsString}, 80 | io::Write, 81 | path, 82 | }; 83 | 84 | use bstr::{ByteSlice, ByteVec}; 85 | use linked_hash_map::LinkedHashMap; 86 | 87 | use super::FileInArchive; 88 | use crate::utils::{logger::warning, EscapedPathDisplay}; 89 | 90 | /// Directory tree 91 | #[derive(Debug, Default)] 92 | pub struct Tree { 93 | file: Option, 94 | children: LinkedHashMap, 95 | } 96 | 97 | impl Tree { 98 | /// Insert a file into the tree 99 | pub fn insert(&mut self, file: FileInArchive) { 100 | self.insert_(file.clone(), file.path.iter()); 101 | } 102 | /// Insert file by traversing the tree recursively 103 | fn insert_(&mut self, file: FileInArchive, mut path: path::Iter) { 104 | // Are there more components in the path? -> traverse tree further 105 | if let Some(part) = path.next() { 106 | // Either insert into an existing child node or create a new one 107 | if let Some(t) = self.children.get_mut(part) { 108 | t.insert_(file, path) 109 | } else { 110 | let mut child = Tree::default(); 111 | child.insert_(file, path); 112 | self.children.insert(part.to_os_string(), child); 113 | } 114 | } else { 115 | // `path` was empty -> we reached our destination and can insert 116 | // `file`, assuming there is no file already there (which meant 117 | // there were 2 files with the same name in the same directory 118 | // which should be impossible in any sane file system) 119 | match &self.file { 120 | None => self.file = Some(file), 121 | Some(file) => { 122 | warning(format!( 123 | "multiple files with the same name in a single directory ({})", 124 | EscapedPathDisplay::new(&file.path), 125 | )); 126 | } 127 | } 128 | } 129 | } 130 | 131 | /// Print the file tree using Unicode line characters 132 | pub fn print(&self, out: &mut impl Write) { 133 | for (i, (name, subtree)) in self.children.iter().enumerate() { 134 | subtree.print_(out, name, "", i == self.children.len() - 1); 135 | } 136 | } 137 | /// Print the tree by traversing it recursively 138 | fn print_(&self, out: &mut impl Write, name: &OsStr, prefix: &str, last: bool) { 139 | // If there are no further elements in the parent directory, add 140 | // "└── " to the prefix, otherwise add "├── " 141 | let final_part = match last { 142 | true => draw::FINAL_LAST, 143 | false => draw::FINAL_BRANCH, 144 | }; 145 | 146 | let _ = write!(out, "{prefix}{final_part}"); 147 | let is_dir = match self.file { 148 | Some(FileInArchive { is_dir, .. }) => is_dir, 149 | None => true, 150 | }; 151 | super::print_entry(out, as ByteVec>::from_os_str_lossy(name).as_bstr(), is_dir); 152 | 153 | // Construct prefix for children, adding either a line if this isn't 154 | // the last entry in the parent dir or empty space if it is. 155 | let mut prefix = prefix.to_owned(); 156 | prefix.push_str(match last { 157 | true => draw::PREFIX_EMPTY, 158 | false => draw::PREFIX_LINE, 159 | }); 160 | // Recursively print all children 161 | for (i, (name, subtree)) in self.children.iter().enumerate() { 162 | subtree.print_(out, name, &prefix, i == self.children.len() - 1); 163 | } 164 | } 165 | } 166 | 167 | impl FromIterator for Tree { 168 | fn from_iter>(iter: I) -> Self { 169 | let mut tree = Self::default(); 170 | for file in iter { 171 | tree.insert(file); 172 | } 173 | tree 174 | } 175 | } 176 | 177 | /// Constants containing the visual parts of which the displayed tree 178 | /// is constructed. 179 | /// 180 | /// They fall into 2 categories: the `PREFIX_*` parts form the first 181 | /// `depth - 1` parts while the `FINAL_*` parts form the last part, 182 | /// right before the entry itself 183 | /// 184 | /// `PREFIX_EMPTY`: the corresponding dir is the last entry in its parent dir 185 | /// `PREFIX_LINE`: there are other entries after the corresponding dir 186 | /// `FINAL_LAST`: this entry is the last entry in its parent dir 187 | /// `FINAL_BRANCH`: there are other entries after this entry 188 | mod draw { 189 | /// the corresponding dir is the last entry in its parent dir 190 | pub const PREFIX_EMPTY: &str = " "; 191 | /// there are other entries after the corresponding dir 192 | pub const PREFIX_LINE: &str = "│ "; 193 | /// this entry is the last entry in its parent dir 194 | pub const FINAL_LAST: &str = "└── "; 195 | /// there are other entries after this entry 196 | pub const FINAL_BRANCH: &str = "├── "; 197 | } 198 | } 199 | -------------------------------------------------------------------------------- /src/main.rs: -------------------------------------------------------------------------------- 1 | pub mod accessible; 2 | pub mod archive; 3 | pub mod check; 4 | pub mod cli; 5 | pub mod commands; 6 | pub mod error; 7 | pub mod extension; 8 | pub mod list; 9 | pub mod utils; 10 | 11 | use std::{env, path::PathBuf}; 12 | 13 | use cli::CliArgs; 14 | use once_cell::sync::Lazy; 15 | 16 | use self::{ 17 | error::{Error, Result}, 18 | utils::{ 19 | logger::{shutdown_logger_and_wait, spawn_logger_thread}, 20 | QuestionAction, QuestionPolicy, 21 | }, 22 | }; 23 | 24 | // Used in BufReader and BufWriter to perform less syscalls 25 | const BUFFER_CAPACITY: usize = 1024 * 32; 26 | 27 | /// Current directory or empty directory 28 | static CURRENT_DIRECTORY: Lazy = Lazy::new(|| env::current_dir().unwrap_or_default()); 29 | 30 | /// The status code returned from `ouch` on error 31 | pub const EXIT_FAILURE: i32 = libc::EXIT_FAILURE; 32 | 33 | fn main() { 34 | spawn_logger_thread(); 35 | let result = run(); 36 | shutdown_logger_and_wait(); 37 | 38 | if let Err(err) = result { 39 | eprintln!("{err}"); 40 | std::process::exit(EXIT_FAILURE); 41 | } 42 | } 43 | 44 | fn run() -> Result<()> { 45 | let (args, skip_questions_positively, file_visibility_policy) = CliArgs::parse_and_validate_args()?; 46 | commands::run(args, skip_questions_positively, file_visibility_policy) 47 | } 48 | -------------------------------------------------------------------------------- /src/utils/colors.rs: -------------------------------------------------------------------------------- 1 | //! Colored output in ouch with bright colors. 2 | 3 | #![allow(dead_code)] 4 | 5 | use std::env; 6 | 7 | use once_cell::sync::Lazy; 8 | 9 | static DISABLE_COLORED_TEXT: Lazy = Lazy::new(|| { 10 | env::var_os("NO_COLOR").is_some() || atty::isnt(atty::Stream::Stdout) || atty::isnt(atty::Stream::Stderr) 11 | }); 12 | 13 | macro_rules! color { 14 | ($name:ident = $value:literal) => { 15 | #[cfg(target_family = "unix")] 16 | /// Inserts color onto text based on configuration 17 | pub static $name: Lazy<&str> = Lazy::new(|| if *DISABLE_COLORED_TEXT { "" } else { $value }); 18 | #[cfg(not(target_family = "unix"))] 19 | pub static $name: &&str = &""; 20 | }; 21 | } 22 | 23 | color!(RESET = "\u{1b}[39m"); 24 | color!(BLACK = "\u{1b}[38;5;8m"); 25 | color!(BLUE = "\u{1b}[38;5;12m"); 26 | color!(CYAN = "\u{1b}[38;5;14m"); 27 | color!(GREEN = "\u{1b}[38;5;10m"); 28 | color!(MAGENTA = "\u{1b}[38;5;13m"); 29 | color!(RED = "\u{1b}[38;5;9m"); 30 | color!(WHITE = "\u{1b}[38;5;15m"); 31 | color!(YELLOW = "\u{1b}[38;5;11m"); 32 | // Requires true color support 33 | color!(ORANGE = "\u{1b}[38;2;255;165;0m"); 34 | color!(STYLE_BOLD = "\u{1b}[1m"); 35 | color!(STYLE_RESET = "\u{1b}[0m"); 36 | color!(ALL_RESET = "\u{1b}[0;39m"); 37 | -------------------------------------------------------------------------------- /src/utils/file_visibility.rs: -------------------------------------------------------------------------------- 1 | use std::path::Path; 2 | 3 | /// Determines which files should be read or ignored during directory walking 4 | pub struct FileVisibilityPolicy { 5 | /// Enables reading .ignore files. 6 | /// 7 | /// Disabled by default. 8 | pub read_ignore: bool, 9 | 10 | /// If enabled, ignores hidden files. 11 | /// 12 | /// Disabled by default 13 | pub read_hidden: bool, 14 | 15 | /// Enables reading .gitignore files. 16 | /// 17 | /// This is enabled by default. 18 | pub read_git_ignore: bool, 19 | 20 | /// Enables reading `.git/info/exclude` files. 21 | pub read_git_exclude: bool, 22 | } 23 | 24 | impl Default for FileVisibilityPolicy { 25 | fn default() -> Self { 26 | Self { 27 | read_ignore: false, 28 | read_hidden: true, 29 | read_git_ignore: false, 30 | read_git_exclude: false, 31 | } 32 | } 33 | } 34 | 35 | impl FileVisibilityPolicy { 36 | pub fn new() -> Self { 37 | Self::default() 38 | } 39 | 40 | #[must_use] 41 | /// Enables reading .ignore files. 42 | pub fn read_ignore(self, read_ignore: bool) -> Self { 43 | Self { read_ignore, ..self } 44 | } 45 | 46 | #[must_use] 47 | /// Enables reading .gitignore files. 48 | pub fn read_git_ignore(self, read_git_ignore: bool) -> Self { 49 | Self { 50 | read_git_ignore, 51 | ..self 52 | } 53 | } 54 | 55 | #[must_use] 56 | /// Enables reading `.git/info/exclude` files. 57 | pub fn read_git_exclude(self, read_git_exclude: bool) -> Self { 58 | Self { 59 | read_git_exclude, 60 | ..self 61 | } 62 | } 63 | 64 | #[must_use] 65 | /// Enables reading `.git/info/exclude` files. 66 | pub fn read_hidden(self, read_hidden: bool) -> Self { 67 | Self { read_hidden, ..self } 68 | } 69 | 70 | /// Walks through a directory using [`ignore::Walk`] 71 | pub fn build_walker(&self, path: impl AsRef) -> ignore::Walk { 72 | let mut builder = ignore::WalkBuilder::new(path); 73 | 74 | builder 75 | .git_exclude(self.read_git_exclude) 76 | .git_ignore(self.read_git_ignore) 77 | .ignore(self.read_ignore) 78 | .hidden(self.read_hidden); 79 | 80 | if self.read_git_ignore { 81 | builder.filter_entry(|p| p.path().file_name().is_some_and(|name| name != ".git")); 82 | } 83 | 84 | builder.build() 85 | } 86 | } 87 | -------------------------------------------------------------------------------- /src/utils/formatting.rs: -------------------------------------------------------------------------------- 1 | use std::{borrow::Cow, cmp, ffi::OsStr, fmt::Display, path::Path}; 2 | 3 | use crate::CURRENT_DIRECTORY; 4 | 5 | /// Converts invalid UTF-8 bytes to the Unicode replacement codepoint (�) in its Display implementation. 6 | pub struct EscapedPathDisplay<'a> { 7 | path: &'a Path, 8 | } 9 | 10 | impl<'a> EscapedPathDisplay<'a> { 11 | pub fn new(path: &'a Path) -> Self { 12 | Self { path } 13 | } 14 | } 15 | 16 | #[cfg(unix)] 17 | impl Display for EscapedPathDisplay<'_> { 18 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 19 | use std::os::unix::prelude::OsStrExt; 20 | 21 | let bstr = bstr::BStr::new(self.path.as_os_str().as_bytes()); 22 | 23 | write!(f, "{bstr}") 24 | } 25 | } 26 | 27 | #[cfg(windows)] 28 | impl Display for EscapedPathDisplay<'_> { 29 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 30 | use std::{char, fmt::Write, os::windows::prelude::OsStrExt}; 31 | 32 | let utf16 = self.path.as_os_str().encode_wide(); 33 | let chars = char::decode_utf16(utf16).map(|decoded| decoded.unwrap_or(char::REPLACEMENT_CHARACTER)); 34 | 35 | for char in chars { 36 | f.write_char(char)?; 37 | } 38 | 39 | Ok(()) 40 | } 41 | } 42 | 43 | /// Converts an OsStr to utf8 with custom formatting. 44 | /// 45 | /// This is different from [`Path::display`]. 46 | /// 47 | /// See for a comparison. 48 | pub fn path_to_str(path: &Path) -> Cow { 49 | os_str_to_str(path.as_ref()) 50 | } 51 | 52 | pub fn os_str_to_str(os_str: &OsStr) -> Cow { 53 | let format = || { 54 | let text = format!("{os_str:?}"); 55 | Cow::Owned(text.trim_matches('"').to_string()) 56 | }; 57 | 58 | os_str.to_str().map_or_else(format, Cow::Borrowed) 59 | } 60 | 61 | /// Removes the current dir from the beginning of a path as it's redundant information, 62 | /// useful for presentation sake. 63 | pub fn strip_cur_dir(source_path: &Path) -> &Path { 64 | let current_dir = &*CURRENT_DIRECTORY; 65 | 66 | source_path.strip_prefix(current_dir).unwrap_or(source_path) 67 | } 68 | 69 | /// Converts a slice of `AsRef` to comma separated String 70 | /// 71 | /// Panics if the slice is empty. 72 | pub fn pretty_format_list_of_paths(paths: &[impl AsRef]) -> String { 73 | let mut iter = paths.iter().map(AsRef::as_ref); 74 | 75 | let first_path = iter.next().unwrap(); 76 | let mut string = path_to_str(first_path).into_owned(); 77 | 78 | for path in iter { 79 | string += ", "; 80 | string += &path_to_str(path); 81 | } 82 | string 83 | } 84 | 85 | /// Display the directory name, but use "current directory" when necessary. 86 | pub fn nice_directory_display(path: &Path) -> Cow { 87 | if path == Path::new(".") { 88 | Cow::Borrowed("current directory") 89 | } else { 90 | path_to_str(path) 91 | } 92 | } 93 | 94 | /// Struct useful to printing bytes as kB, MB, GB, etc. 95 | pub struct Bytes(f64); 96 | 97 | impl Bytes { 98 | const UNIT_PREFIXES: [&'static str; 6] = ["", "ki", "Mi", "Gi", "Ti", "Pi"]; 99 | 100 | /// Create a new Bytes. 101 | pub fn new(bytes: u64) -> Self { 102 | Self(bytes as f64) 103 | } 104 | } 105 | 106 | impl std::fmt::Display for Bytes { 107 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 108 | let num = self.0; 109 | 110 | debug_assert!(num >= 0.0); 111 | if num < 1_f64 { 112 | return write!(f, "{:>6.2} B", num); 113 | } 114 | 115 | let delimiter = 1000_f64; 116 | let exponent = cmp::min((num.ln() / 6.90775).floor() as i32, 4); 117 | 118 | write!( 119 | f, 120 | "{:>6.2} {:>2}B", 121 | num / delimiter.powi(exponent), 122 | Bytes::UNIT_PREFIXES[exponent as usize], 123 | ) 124 | } 125 | } 126 | 127 | #[cfg(test)] 128 | mod tests { 129 | use super::*; 130 | 131 | #[test] 132 | fn test_pretty_bytes_formatting() { 133 | fn format_bytes(bytes: u64) -> String { 134 | format!("{}", Bytes::new(bytes)) 135 | } 136 | let b = 1; 137 | let kb = b * 1000; 138 | let mb = kb * 1000; 139 | let gb = mb * 1000; 140 | 141 | assert_eq!(" 0.00 B", format_bytes(0)); // This is weird 142 | assert_eq!(" 1.00 B", format_bytes(b)); 143 | assert_eq!("999.00 B", format_bytes(b * 999)); 144 | assert_eq!(" 12.00 MiB", format_bytes(mb * 12)); 145 | assert_eq!("123.00 MiB", format_bytes(mb * 123)); 146 | assert_eq!(" 5.50 MiB", format_bytes(mb * 5 + kb * 500)); 147 | assert_eq!(" 7.54 GiB", format_bytes(gb * 7 + 540 * mb)); 148 | assert_eq!(" 1.20 TiB", format_bytes(gb * 1200)); 149 | 150 | // bytes 151 | assert_eq!("234.00 B", format_bytes(234)); 152 | assert_eq!("999.00 B", format_bytes(999)); 153 | // kilobytes 154 | assert_eq!(" 2.23 kiB", format_bytes(2234)); 155 | assert_eq!(" 62.50 kiB", format_bytes(62500)); 156 | assert_eq!("329.99 kiB", format_bytes(329990)); 157 | // megabytes 158 | assert_eq!(" 2.75 MiB", format_bytes(2750000)); 159 | assert_eq!(" 55.00 MiB", format_bytes(55000000)); 160 | assert_eq!("987.65 MiB", format_bytes(987654321)); 161 | // gigabytes 162 | assert_eq!(" 5.28 GiB", format_bytes(5280000000)); 163 | assert_eq!(" 95.20 GiB", format_bytes(95200000000)); 164 | assert_eq!("302.00 GiB", format_bytes(302000000000)); 165 | assert_eq!("302.99 GiB", format_bytes(302990000000)); 166 | // Weird aproximation cases: 167 | assert_eq!("999.90 GiB", format_bytes(999900000000)); 168 | assert_eq!(" 1.00 TiB", format_bytes(999990000000)); 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /src/utils/fs.rs: -------------------------------------------------------------------------------- 1 | //! Filesystem utility functions. 2 | 3 | use std::{ 4 | env, 5 | io::Read, 6 | path::{Path, PathBuf}, 7 | }; 8 | 9 | use fs_err as fs; 10 | 11 | use super::{question::FileConflitOperation, user_wants_to_overwrite}; 12 | use crate::{ 13 | extension::Extension, 14 | utils::{logger::info_accessible, EscapedPathDisplay, QuestionAction}, 15 | QuestionPolicy, 16 | }; 17 | 18 | pub fn is_path_stdin(path: &Path) -> bool { 19 | path.as_os_str() == "-" 20 | } 21 | 22 | /// Check if &Path exists, if it does then ask the user if they want to overwrite or rename it. 23 | /// If the user want to overwrite then the file or directory will be removed and returned the same input path 24 | /// If the user want to rename then nothing will be removed and a new path will be returned with a new name 25 | /// 26 | /// * `Ok(None)` means the user wants to cancel the operation 27 | /// * `Ok(Some(path))` returns a valid PathBuf without any another file or directory with the same name 28 | /// * `Err(_)` is an error 29 | pub fn resolve_path_conflict( 30 | path: &Path, 31 | question_policy: QuestionPolicy, 32 | question_action: QuestionAction, 33 | ) -> crate::Result> { 34 | if path.exists() { 35 | match user_wants_to_overwrite(path, question_policy, question_action)? { 36 | FileConflitOperation::Cancel => Ok(None), 37 | FileConflitOperation::Overwrite => { 38 | remove_file_or_dir(path)?; 39 | Ok(Some(path.to_path_buf())) 40 | } 41 | FileConflitOperation::Rename => { 42 | let renamed_path = rename_for_available_filename(path); 43 | Ok(Some(renamed_path)) 44 | } 45 | FileConflitOperation::Merge => Ok(Some(path.to_path_buf())), 46 | } 47 | } else { 48 | Ok(Some(path.to_path_buf())) 49 | } 50 | } 51 | 52 | pub fn remove_file_or_dir(path: &Path) -> crate::Result<()> { 53 | if path.is_dir() { 54 | fs::remove_dir_all(path)?; 55 | } else if path.is_file() { 56 | fs::remove_file(path)?; 57 | } 58 | Ok(()) 59 | } 60 | 61 | /// Create a new path renaming the "filename" from &Path for a available name in the same directory 62 | pub fn rename_for_available_filename(path: &Path) -> PathBuf { 63 | let mut renamed_path = rename_or_increment_filename(path); 64 | while renamed_path.exists() { 65 | renamed_path = rename_or_increment_filename(&renamed_path); 66 | } 67 | renamed_path 68 | } 69 | 70 | /// Create a new path renaming the "filename" from &Path to `filename_1` 71 | /// if its name already ends with `_` and some number, then it increments the number 72 | /// Example: 73 | /// - `file.txt` -> `file_1.txt` 74 | /// - `file_1.txt` -> `file_2.txt` 75 | pub fn rename_or_increment_filename(path: &Path) -> PathBuf { 76 | let parent = path.parent().unwrap_or_else(|| Path::new("")); 77 | let filename = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); 78 | let extension = path.extension().and_then(|s| s.to_str()).unwrap_or(""); 79 | 80 | let new_filename = match filename.rsplit_once('_') { 81 | Some((base, number_str)) if number_str.chars().all(char::is_numeric) => { 82 | let number = number_str.parse::().unwrap_or(0); 83 | format!("{}_{}", base, number + 1) 84 | } 85 | _ => format!("{}_1", filename), 86 | }; 87 | 88 | let mut new_path = parent.join(new_filename); 89 | if !extension.is_empty() { 90 | new_path.set_extension(extension); 91 | } 92 | 93 | new_path 94 | } 95 | 96 | /// Creates a directory at the path, if there is nothing there. 97 | pub fn create_dir_if_non_existent(path: &Path) -> crate::Result<()> { 98 | if !path.exists() { 99 | fs::create_dir_all(path)?; 100 | // creating a directory is an important change to the file system we 101 | // should always inform the user about 102 | info_accessible(format!("Directory {} created", EscapedPathDisplay::new(path))); 103 | } 104 | Ok(()) 105 | } 106 | 107 | /// Returns current directory, but before change the process' directory to the 108 | /// one that contains the file pointed to by `filename`. 109 | pub fn cd_into_same_dir_as(filename: &Path) -> crate::Result { 110 | let previous_location = env::current_dir()?; 111 | 112 | let parent = filename.parent().ok_or(crate::Error::CompressingRootFolder)?; 113 | env::set_current_dir(parent)?; 114 | 115 | Ok(previous_location) 116 | } 117 | 118 | /// Try to detect the file extension by looking for known magic strings 119 | /// Source: 120 | pub fn try_infer_extension(path: &Path) -> Option { 121 | fn is_zip(buf: &[u8]) -> bool { 122 | buf.len() >= 3 123 | && buf[..=1] == [0x50, 0x4B] 124 | && (buf[2..=3] == [0x3, 0x4] || buf[2..=3] == [0x5, 0x6] || buf[2..=3] == [0x7, 0x8]) 125 | } 126 | fn is_tar(buf: &[u8]) -> bool { 127 | buf.len() > 261 && buf[257..=261] == [0x75, 0x73, 0x74, 0x61, 0x72] 128 | } 129 | fn is_gz(buf: &[u8]) -> bool { 130 | buf.starts_with(&[0x1F, 0x8B, 0x8]) 131 | } 132 | fn is_bz2(buf: &[u8]) -> bool { 133 | buf.starts_with(&[0x42, 0x5A, 0x68]) 134 | } 135 | fn is_bz3(buf: &[u8]) -> bool { 136 | buf.starts_with(b"BZ3v1") 137 | } 138 | fn is_xz(buf: &[u8]) -> bool { 139 | buf.starts_with(&[0xFD, 0x37, 0x7A, 0x58, 0x5A, 0x00]) 140 | } 141 | fn is_lz4(buf: &[u8]) -> bool { 142 | buf.starts_with(&[0x04, 0x22, 0x4D, 0x18]) 143 | } 144 | fn is_sz(buf: &[u8]) -> bool { 145 | buf.starts_with(&[0xFF, 0x06, 0x00, 0x00, 0x73, 0x4E, 0x61, 0x50, 0x70, 0x59]) 146 | } 147 | fn is_zst(buf: &[u8]) -> bool { 148 | buf.starts_with(&[0x28, 0xB5, 0x2F, 0xFD]) 149 | } 150 | fn is_rar(buf: &[u8]) -> bool { 151 | // ref https://www.rarlab.com/technote.htm#rarsign 152 | // RAR 5.0 8 bytes length signature: 0x52 0x61 0x72 0x21 0x1A 0x07 0x01 0x00 153 | // RAR 4.x 7 bytes length signature: 0x52 0x61 0x72 0x21 0x1A 0x07 0x00 154 | buf.len() >= 7 155 | && buf.starts_with(&[0x52, 0x61, 0x72, 0x21, 0x1A, 0x07]) 156 | && (buf[6] == 0x00 || (buf.len() >= 8 && buf[6..=7] == [0x01, 0x00])) 157 | } 158 | fn is_sevenz(buf: &[u8]) -> bool { 159 | buf.starts_with(&[0x37, 0x7A, 0xBC, 0xAF, 0x27, 0x1C]) 160 | } 161 | 162 | let buf = { 163 | let mut buf = [0; 270]; 164 | 165 | // Error cause will be ignored, so use std::fs instead of fs_err 166 | let result = std::fs::File::open(path).map(|mut file| file.read(&mut buf)); 167 | 168 | // In case of file open or read failure, could not infer a extension 169 | if result.is_err() { 170 | return None; 171 | } 172 | buf 173 | }; 174 | 175 | use crate::extension::CompressionFormat::*; 176 | if is_zip(&buf) { 177 | Some(Extension::new(&[Zip], "zip")) 178 | } else if is_tar(&buf) { 179 | Some(Extension::new(&[Tar], "tar")) 180 | } else if is_gz(&buf) { 181 | Some(Extension::new(&[Gzip], "gz")) 182 | } else if is_bz2(&buf) { 183 | Some(Extension::new(&[Bzip], "bz2")) 184 | } else if is_bz3(&buf) { 185 | Some(Extension::new(&[Bzip3], "bz3")) 186 | } else if is_xz(&buf) { 187 | Some(Extension::new(&[Lzma], "xz")) 188 | } else if is_lz4(&buf) { 189 | Some(Extension::new(&[Lz4], "lz4")) 190 | } else if is_sz(&buf) { 191 | Some(Extension::new(&[Snappy], "sz")) 192 | } else if is_zst(&buf) { 193 | Some(Extension::new(&[Zstd], "zst")) 194 | } else if is_rar(&buf) { 195 | Some(Extension::new(&[Rar], "rar")) 196 | } else if is_sevenz(&buf) { 197 | Some(Extension::new(&[SevenZip], "7z")) 198 | } else { 199 | None 200 | } 201 | } 202 | -------------------------------------------------------------------------------- /src/utils/io.rs: -------------------------------------------------------------------------------- 1 | use std::io::{self, stderr, stdout, StderrLock, StdoutLock, Write}; 2 | 3 | use crate::utils::logger; 4 | 5 | type StdioOutputLocks = (StdoutLock<'static>, StderrLock<'static>); 6 | 7 | pub fn lock_and_flush_output_stdio() -> io::Result { 8 | logger::flush_messages(); 9 | 10 | let mut stdout = stdout().lock(); 11 | stdout.flush()?; 12 | let mut stderr = stderr().lock(); 13 | stderr.flush()?; 14 | 15 | Ok((stdout, stderr)) 16 | } 17 | -------------------------------------------------------------------------------- /src/utils/logger.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | sync::{mpsc, Arc, Barrier, OnceLock}, 3 | thread, 4 | }; 5 | 6 | pub use logger_thread::spawn_logger_thread; 7 | 8 | use super::colors::{ORANGE, RESET, YELLOW}; 9 | use crate::accessible::is_running_in_accessible_mode; 10 | 11 | /// Asks logger to shutdown and waits till it flushes all pending messages. 12 | #[track_caller] 13 | pub fn shutdown_logger_and_wait() { 14 | logger_thread::send_shutdown_command_and_wait(); 15 | } 16 | 17 | /// Asks logger to flush all messages, useful before starting STDIN interaction. 18 | #[track_caller] 19 | pub fn flush_messages() { 20 | logger_thread::send_flush_command_and_wait(); 21 | } 22 | 23 | /// An `[INFO]` log to be displayed if we're not running accessibility mode. 24 | /// 25 | /// Same as `.info_accessible()`, but only displayed if accessibility mode 26 | /// is turned off, which is detected by the function 27 | /// `is_running_in_accessible_mode`. 28 | /// 29 | /// Read more about accessibility mode in `accessible.rs`. 30 | #[track_caller] 31 | pub fn info(contents: String) { 32 | info_with_accessibility(contents, false); 33 | } 34 | 35 | /// An `[INFO]` log to be displayed. 36 | /// 37 | /// Same as `.info()`, but also displays if `is_running_in_accessible_mode` 38 | /// returns `true`. 39 | /// 40 | /// Read more about accessibility mode in `accessible.rs`. 41 | #[track_caller] 42 | pub fn info_accessible(contents: String) { 43 | info_with_accessibility(contents, true); 44 | } 45 | 46 | #[track_caller] 47 | fn info_with_accessibility(contents: String, accessible: bool) { 48 | logger_thread::send_print_command(PrintMessage { 49 | contents, 50 | accessible, 51 | level: MessageLevel::Info, 52 | }); 53 | } 54 | 55 | #[track_caller] 56 | pub fn warning(contents: String) { 57 | logger_thread::send_print_command(PrintMessage { 58 | contents, 59 | // Warnings are important and unlikely to flood, so they should be displayed 60 | accessible: true, 61 | level: MessageLevel::Warning, 62 | }); 63 | } 64 | 65 | #[derive(Debug)] 66 | enum LoggerCommand { 67 | Print(PrintMessage), 68 | Flush { finished_barrier: Arc }, 69 | FlushAndShutdown { finished_barrier: Arc }, 70 | } 71 | 72 | /// Message object used for sending logs from worker threads to a logging thread via channels. 73 | /// See 74 | #[derive(Debug)] 75 | struct PrintMessage { 76 | contents: String, 77 | accessible: bool, 78 | level: MessageLevel, 79 | } 80 | 81 | impl PrintMessage { 82 | fn to_formatted_message(&self) -> Option { 83 | match self.level { 84 | MessageLevel::Info => { 85 | if self.accessible { 86 | if is_running_in_accessible_mode() { 87 | Some(format!("{}Info:{} {}", *YELLOW, *RESET, self.contents)) 88 | } else { 89 | Some(format!("{}[INFO]{} {}", *YELLOW, *RESET, self.contents)) 90 | } 91 | } else if !is_running_in_accessible_mode() { 92 | Some(format!("{}[INFO]{} {}", *YELLOW, *RESET, self.contents)) 93 | } else { 94 | None 95 | } 96 | } 97 | MessageLevel::Warning => { 98 | if is_running_in_accessible_mode() { 99 | Some(format!("{}Warning:{} {}", *ORANGE, *RESET, self.contents)) 100 | } else { 101 | Some(format!("{}[WARNING]{} {}", *ORANGE, *RESET, self.contents)) 102 | } 103 | } 104 | } 105 | } 106 | } 107 | 108 | #[derive(Debug, PartialEq)] 109 | enum MessageLevel { 110 | Info, 111 | Warning, 112 | } 113 | 114 | mod logger_thread { 115 | use std::{ 116 | sync::{mpsc::RecvTimeoutError, Arc, Barrier}, 117 | time::Duration, 118 | }; 119 | 120 | use super::*; 121 | 122 | type LogReceiver = mpsc::Receiver; 123 | type LogSender = mpsc::Sender; 124 | 125 | static SENDER: OnceLock = OnceLock::new(); 126 | 127 | #[track_caller] 128 | fn setup_channel() -> Option { 129 | let mut optional = None; 130 | SENDER.get_or_init(|| { 131 | let (tx, rx) = mpsc::channel(); 132 | optional = Some(rx); 133 | tx 134 | }); 135 | optional 136 | } 137 | 138 | #[track_caller] 139 | fn get_sender() -> &'static LogSender { 140 | SENDER.get().expect("No sender, you need to call `setup_channel` first") 141 | } 142 | 143 | #[track_caller] 144 | pub(super) fn send_print_command(msg: PrintMessage) { 145 | if cfg!(test) { 146 | spawn_logger_thread(); 147 | } 148 | get_sender() 149 | .send(LoggerCommand::Print(msg)) 150 | .expect("Failed to send print command"); 151 | } 152 | 153 | #[track_caller] 154 | pub(super) fn send_flush_command_and_wait() { 155 | let barrier = Arc::new(Barrier::new(2)); 156 | 157 | get_sender() 158 | .send(LoggerCommand::Flush { 159 | finished_barrier: barrier.clone(), 160 | }) 161 | .expect("Failed to send flush command"); 162 | 163 | barrier.wait(); 164 | } 165 | 166 | #[track_caller] 167 | pub(super) fn send_shutdown_command_and_wait() { 168 | let barrier = Arc::new(Barrier::new(2)); 169 | 170 | get_sender() 171 | .send(LoggerCommand::FlushAndShutdown { 172 | finished_barrier: barrier.clone(), 173 | }) 174 | .expect("Failed to send shutdown command"); 175 | 176 | barrier.wait(); 177 | } 178 | 179 | pub fn spawn_logger_thread() { 180 | if let Some(log_receiver) = setup_channel() { 181 | thread::spawn(move || run_logger(log_receiver)); 182 | } 183 | } 184 | 185 | fn run_logger(log_receiver: LogReceiver) { 186 | const FLUSH_TIMEOUT: Duration = Duration::from_millis(200); 187 | 188 | let mut buffer = Vec::::with_capacity(16); 189 | 190 | loop { 191 | let msg = match log_receiver.recv_timeout(FLUSH_TIMEOUT) { 192 | Ok(msg) => msg, 193 | Err(RecvTimeoutError::Timeout) => { 194 | flush_logs_to_stderr(&mut buffer); 195 | continue; 196 | } 197 | Err(RecvTimeoutError::Disconnected) => unreachable!("sender is static"), 198 | }; 199 | 200 | match msg { 201 | LoggerCommand::Print(msg) => { 202 | // Append message to buffer 203 | if let Some(msg) = msg.to_formatted_message() { 204 | buffer.push(msg); 205 | } 206 | 207 | if buffer.len() == buffer.capacity() { 208 | flush_logs_to_stderr(&mut buffer); 209 | } 210 | } 211 | LoggerCommand::Flush { finished_barrier } => { 212 | flush_logs_to_stderr(&mut buffer); 213 | finished_barrier.wait(); 214 | } 215 | LoggerCommand::FlushAndShutdown { finished_barrier } => { 216 | flush_logs_to_stderr(&mut buffer); 217 | finished_barrier.wait(); 218 | return; 219 | } 220 | } 221 | } 222 | } 223 | 224 | fn flush_logs_to_stderr(buffer: &mut Vec) { 225 | if !buffer.is_empty() { 226 | let text = buffer.join("\n"); 227 | eprintln!("{text}"); 228 | buffer.clear(); 229 | } 230 | } 231 | } 232 | -------------------------------------------------------------------------------- /src/utils/mod.rs: -------------------------------------------------------------------------------- 1 | //! Random and miscellaneous utils used in ouch. 2 | //! 3 | //! In here we have the logic for custom formatting, some file and directory utils, and user 4 | //! stdin interaction helpers. 5 | 6 | pub mod colors; 7 | mod file_visibility; 8 | mod formatting; 9 | mod fs; 10 | pub mod io; 11 | pub mod logger; 12 | mod question; 13 | 14 | pub use self::{ 15 | file_visibility::FileVisibilityPolicy, 16 | formatting::{ 17 | nice_directory_display, os_str_to_str, path_to_str, pretty_format_list_of_paths, strip_cur_dir, Bytes, 18 | EscapedPathDisplay, 19 | }, 20 | fs::{ 21 | cd_into_same_dir_as, create_dir_if_non_existent, is_path_stdin, remove_file_or_dir, 22 | rename_for_available_filename, resolve_path_conflict, try_infer_extension, 23 | }, 24 | question::{ 25 | ask_to_create_file, user_wants_to_continue, user_wants_to_overwrite, FileConflitOperation, QuestionAction, 26 | QuestionPolicy, 27 | }, 28 | utf8::{get_invalid_utf8_paths, is_invalid_utf8}, 29 | }; 30 | 31 | mod utf8 { 32 | use std::{ffi::OsStr, path::PathBuf}; 33 | 34 | /// Check, without allocating, if os_str can be converted into &str 35 | pub fn is_invalid_utf8(os_str: impl AsRef) -> bool { 36 | os_str.as_ref().to_str().is_none() 37 | } 38 | 39 | /// Filter out list of paths that are not utf8 valid 40 | pub fn get_invalid_utf8_paths(paths: &[PathBuf]) -> Vec<&PathBuf> { 41 | paths.iter().filter(|path| is_invalid_utf8(path)).collect() 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /src/utils/question.rs: -------------------------------------------------------------------------------- 1 | //! Utils related to asking [Y/n] questions to the user. 2 | //! 3 | //! Example: 4 | //! "Do you want to overwrite 'archive.tar.gz'? [Y/n]" 5 | 6 | use std::{ 7 | borrow::Cow, 8 | io::{stdin, BufRead, IsTerminal}, 9 | path::Path, 10 | }; 11 | 12 | use fs_err as fs; 13 | 14 | use crate::{ 15 | accessible::is_running_in_accessible_mode, 16 | error::{Error, FinalError, Result}, 17 | utils::{self, colors, formatting::path_to_str, io::lock_and_flush_output_stdio, strip_cur_dir}, 18 | }; 19 | 20 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 21 | /// Determines if overwrite questions should be skipped or asked to the user 22 | pub enum QuestionPolicy { 23 | /// Ask the user every time 24 | Ask, 25 | /// Set by `--yes`, will say 'Y' to all overwrite questions 26 | AlwaysYes, 27 | /// Set by `--no`, will say 'N' to all overwrite questions 28 | AlwaysNo, 29 | } 30 | 31 | #[derive(Debug, PartialEq, Eq, Clone, Copy)] 32 | /// Determines which action is being questioned 33 | pub enum QuestionAction { 34 | /// question called from a compression function 35 | Compression, 36 | /// question called from a decompression function 37 | Decompression, 38 | } 39 | 40 | #[derive(Default)] 41 | /// Determines which action to do when there is a file conflict 42 | pub enum FileConflitOperation { 43 | #[default] 44 | /// Cancel the operation 45 | Cancel, 46 | /// Overwrite the existing file with the new one 47 | Overwrite, 48 | /// Rename the file 49 | /// It'll be put "_1" at the end of the filename or "_2","_3","_4".. if already exists 50 | Rename, 51 | /// Merge conflicting folders 52 | Merge, 53 | } 54 | 55 | /// Check if QuestionPolicy flags were set, otherwise, ask user if they want to overwrite. 56 | pub fn user_wants_to_overwrite( 57 | path: &Path, 58 | question_policy: QuestionPolicy, 59 | question_action: QuestionAction, 60 | ) -> crate::Result { 61 | use FileConflitOperation as Op; 62 | 63 | match question_policy { 64 | QuestionPolicy::AlwaysYes => Ok(Op::Overwrite), 65 | QuestionPolicy::AlwaysNo => Ok(Op::Cancel), 66 | QuestionPolicy::Ask => ask_file_conflict_operation(path, question_action), 67 | } 68 | } 69 | 70 | /// Ask the user if they want to overwrite or rename the &Path 71 | pub fn ask_file_conflict_operation(path: &Path, question_action: QuestionAction) -> Result { 72 | use FileConflitOperation as Op; 73 | 74 | let path = path_to_str(strip_cur_dir(path)); 75 | match question_action { 76 | QuestionAction::Compression => ChoicePrompt::new( 77 | format!("Do you want to overwrite {path}?"), 78 | [ 79 | ("yes", Op::Overwrite, *colors::GREEN), 80 | ("no", Op::Cancel, *colors::RED), 81 | ("rename", Op::Rename, *colors::BLUE), 82 | ], 83 | ) 84 | .ask(), 85 | QuestionAction::Decompression => ChoicePrompt::new( 86 | format!("Do you want to overwrite {path}?"), 87 | [ 88 | ("yes", Op::Overwrite, *colors::GREEN), 89 | ("no", Op::Cancel, *colors::RED), 90 | ("rename", Op::Rename, *colors::BLUE), 91 | ("merge", Op::Merge, *colors::ORANGE), 92 | ], 93 | ) 94 | .ask(), 95 | } 96 | } 97 | 98 | /// Create the file if it doesn't exist and if it does then ask to overwrite it. 99 | /// If the user doesn't want to overwrite then we return [`Ok(None)`] 100 | pub fn ask_to_create_file( 101 | path: &Path, 102 | question_policy: QuestionPolicy, 103 | question_action: QuestionAction, 104 | ) -> Result> { 105 | match fs::OpenOptions::new().write(true).create_new(true).open(path) { 106 | Ok(w) => Ok(Some(w)), 107 | Err(e) if e.kind() == std::io::ErrorKind::AlreadyExists => { 108 | let action = match question_policy { 109 | QuestionPolicy::AlwaysYes => FileConflitOperation::Overwrite, 110 | QuestionPolicy::AlwaysNo => FileConflitOperation::Cancel, 111 | QuestionPolicy::Ask => ask_file_conflict_operation(path, question_action)?, 112 | }; 113 | 114 | match action { 115 | FileConflitOperation::Merge => Ok(Some(fs::File::create(path)?)), 116 | FileConflitOperation::Overwrite => { 117 | utils::remove_file_or_dir(path)?; 118 | Ok(Some(fs::File::create(path)?)) 119 | } 120 | FileConflitOperation::Cancel => Ok(None), 121 | FileConflitOperation::Rename => { 122 | let renamed_file_path = utils::rename_for_available_filename(path); 123 | Ok(Some(fs::File::create(renamed_file_path)?)) 124 | } 125 | } 126 | } 127 | Err(e) => Err(Error::from(e)), 128 | } 129 | } 130 | 131 | /// Check if QuestionPolicy flags were set, otherwise, ask the user if they want to continue. 132 | pub fn user_wants_to_continue( 133 | path: &Path, 134 | question_policy: QuestionPolicy, 135 | question_action: QuestionAction, 136 | ) -> crate::Result { 137 | match question_policy { 138 | QuestionPolicy::AlwaysYes => Ok(true), 139 | QuestionPolicy::AlwaysNo => Ok(false), 140 | QuestionPolicy::Ask => { 141 | let action = match question_action { 142 | QuestionAction::Compression => "compress", 143 | QuestionAction::Decompression => "decompress", 144 | }; 145 | let path = path_to_str(strip_cur_dir(path)); 146 | let path = Some(&*path); 147 | let placeholder = Some("FILE"); 148 | Confirmation::new(&format!("Do you want to {action} 'FILE'?"), placeholder).ask(path) 149 | } 150 | } 151 | } 152 | 153 | /// Choise dialog for end user with [option1/option2/...] question. 154 | /// Each option is a [Choice] entity, holding a value "T" returned when that option is selected 155 | pub struct ChoicePrompt<'a, T: Default> { 156 | /// The message to be displayed before the options 157 | /// e.g.: "Do you want to overwrite 'FILE'?" 158 | pub prompt: String, 159 | 160 | pub choises: Vec>, 161 | } 162 | 163 | /// A single choice showed as a option to user in a [ChoicePrompt] 164 | /// It holds a label and a color to display to user and a real value to be returned 165 | pub struct Choice<'a, T: Default> { 166 | label: &'a str, 167 | value: T, 168 | color: &'a str, 169 | } 170 | 171 | impl<'a, T: Default> ChoicePrompt<'a, T> { 172 | /// Creates a new Confirmation. 173 | pub fn new(prompt: impl Into, choises: impl IntoIterator) -> Self { 174 | Self { 175 | prompt: prompt.into(), 176 | choises: choises 177 | .into_iter() 178 | .map(|(label, value, color)| Choice { label, value, color }) 179 | .collect(), 180 | } 181 | } 182 | 183 | /// Creates user message and receives a input to be compared with choises "label" 184 | /// and returning the real value of the choise selected 185 | pub fn ask(mut self) -> crate::Result { 186 | let message = self.prompt; 187 | 188 | #[cfg(not(feature = "allow_piped_choice"))] 189 | if !stdin().is_terminal() { 190 | eprintln!("{}", message); 191 | eprintln!("Pass --yes to proceed"); 192 | return Ok(T::default()); 193 | } 194 | 195 | let _locks = lock_and_flush_output_stdio()?; 196 | let mut stdin_lock = stdin().lock(); 197 | 198 | // Ask the same question to end while no valid answers are given 199 | loop { 200 | let choice_prompt = if is_running_in_accessible_mode() { 201 | self.choises 202 | .iter() 203 | .map(|choise| format!("{}{}{}", choise.color, choise.label, *colors::RESET)) 204 | .collect::>() 205 | .join("/") 206 | } else { 207 | let choises = self 208 | .choises 209 | .iter() 210 | .map(|choise| { 211 | format!( 212 | "{}{}{}", 213 | choise.color, 214 | choise 215 | .label 216 | .chars() 217 | .nth(0) 218 | .expect("dev error, should be reported, we checked this won't happen"), 219 | *colors::RESET 220 | ) 221 | }) 222 | .collect::>() 223 | .join("/"); 224 | 225 | format!("[{}]", choises) 226 | }; 227 | 228 | eprintln!("{} {}", message, choice_prompt); 229 | 230 | let mut answer = String::new(); 231 | let bytes_read = stdin_lock.read_line(&mut answer)?; 232 | 233 | if bytes_read == 0 { 234 | let error = FinalError::with_title("Unexpected EOF when asking question.") 235 | .detail("When asking the user:") 236 | .detail(format!(" \"{message}\"")) 237 | .detail("Expected one of the options as answer, but found EOF instead.") 238 | .hint("If using Ouch in scripting, consider using `--yes` and `--no`."); 239 | 240 | return Err(error.into()); 241 | } 242 | 243 | answer.make_ascii_lowercase(); 244 | let answer = answer.trim(); 245 | 246 | let chosen_index = self.choises.iter().position(|choise| choise.label.starts_with(answer)); 247 | 248 | if let Some(i) = chosen_index { 249 | return Ok(self.choises.remove(i).value); 250 | } 251 | } 252 | } 253 | } 254 | 255 | /// Confirmation dialog for end user with [Y/n] question. 256 | /// 257 | /// If the placeholder is found in the prompt text, it will be replaced to form the final message. 258 | pub struct Confirmation<'a> { 259 | /// The message to be displayed with the placeholder text in it. 260 | /// e.g.: "Do you want to overwrite 'FILE'?" 261 | pub prompt: &'a str, 262 | 263 | /// The placeholder text that will be replaced in the `ask` function: 264 | /// e.g.: Some("FILE") 265 | pub placeholder: Option<&'a str>, 266 | } 267 | 268 | impl<'a> Confirmation<'a> { 269 | /// Creates a new Confirmation. 270 | pub const fn new(prompt: &'a str, pattern: Option<&'a str>) -> Self { 271 | Self { 272 | prompt, 273 | placeholder: pattern, 274 | } 275 | } 276 | 277 | /// Creates user message and receives a boolean input to be used on the program 278 | pub fn ask(&self, substitute: Option<&'a str>) -> crate::Result { 279 | let message = match (self.placeholder, substitute) { 280 | (None, _) => Cow::Borrowed(self.prompt), 281 | (Some(_), None) => unreachable!("dev error, should be reported, we checked this won't happen"), 282 | (Some(placeholder), Some(subs)) => Cow::Owned(self.prompt.replace(placeholder, subs)), 283 | }; 284 | 285 | #[cfg(not(feature = "allow_piped_choice"))] 286 | if !stdin().is_terminal() { 287 | eprintln!("{}", message); 288 | eprintln!("Pass --yes to proceed"); 289 | return Ok(false); 290 | } 291 | 292 | let _locks = lock_and_flush_output_stdio()?; 293 | let mut stdin_lock = stdin().lock(); 294 | 295 | // Ask the same question to end while no valid answers are given 296 | loop { 297 | if is_running_in_accessible_mode() { 298 | eprintln!( 299 | "{} {}yes{}/{}no{}: ", 300 | message, 301 | *colors::GREEN, 302 | *colors::RESET, 303 | *colors::RED, 304 | *colors::RESET 305 | ); 306 | } else { 307 | eprintln!( 308 | "{} [{}Y{}/{}n{}] ", 309 | message, 310 | *colors::GREEN, 311 | *colors::RESET, 312 | *colors::RED, 313 | *colors::RESET 314 | ); 315 | } 316 | 317 | let mut answer = String::new(); 318 | let bytes_read = stdin_lock.read_line(&mut answer)?; 319 | 320 | if bytes_read == 0 { 321 | let error = FinalError::with_title("Unexpected EOF when asking question.") 322 | .detail("When asking the user:") 323 | .detail(format!(" \"{message}\"")) 324 | .detail("Expected 'y' or 'n' as answer, but found EOF instead.") 325 | .hint("If using Ouch in scripting, consider using `--yes` and `--no`."); 326 | 327 | return Err(error.into()); 328 | } 329 | 330 | answer.make_ascii_lowercase(); 331 | match answer.trim() { 332 | "" | "y" | "yes" => return Ok(true), 333 | "n" | "no" => return Ok(false), 334 | _ => continue, // Try again 335 | } 336 | } 337 | } 338 | } 339 | -------------------------------------------------------------------------------- /tests/data/testfile.rar3.rar.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouch-org/ouch/11344a6ffd58d8f16dc859a313aaa14421398d81/tests/data/testfile.rar3.rar.gz -------------------------------------------------------------------------------- /tests/data/testfile.rar5.rar: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ouch-org/ouch/11344a6ffd58d8f16dc859a313aaa14421398d81/tests/data/testfile.rar5.rar -------------------------------------------------------------------------------- /tests/mime.rs: -------------------------------------------------------------------------------- 1 | #[macro_use] 2 | mod utils; 3 | 4 | use rand::{rngs::SmallRng, SeedableRng}; 5 | use tempfile::NamedTempFile; 6 | 7 | use crate::utils::write_random_content; 8 | 9 | #[test] 10 | /// Makes sure that the files ouch produces are what they claim to be, checking their 11 | /// types through MIME sniffing. 12 | fn sanity_check_through_mime() { 13 | let temp_dir = tempfile::tempdir().expect("to build a temporary directory"); 14 | let temp_dir_path = temp_dir.path(); 15 | 16 | let test_file = &mut NamedTempFile::new_in(temp_dir_path).expect("to be able to build a temporary file"); 17 | write_random_content(test_file, &mut SmallRng::from_entropy()); 18 | 19 | let formats = [ 20 | "7z", "tar", "zip", "tar.gz", "tgz", "tbz", "tbz2", "txz", "tlzma", "tzst", "tar.bz", "tar.bz2", "tar.lzma", 21 | "tar.xz", "tar.zst", 22 | ]; 23 | 24 | let expected_mimes = [ 25 | "application/x-7z-compressed", 26 | "application/x-tar", 27 | "application/zip", 28 | "application/gzip", 29 | "application/gzip", 30 | "application/x-bzip2", 31 | "application/x-bzip2", 32 | "application/x-xz", 33 | "application/x-xz", 34 | "application/zstd", 35 | "application/x-bzip2", 36 | "application/x-bzip2", 37 | "application/x-xz", 38 | "application/x-xz", 39 | "application/zstd", 40 | ]; 41 | 42 | assert_eq!(formats.len(), expected_mimes.len()); 43 | 44 | for (format, expected_mime) in formats.iter().zip(expected_mimes.iter()) { 45 | let path_to_compress = test_file.path(); 46 | 47 | let compressed_file_path = &format!("{}.{}", path_to_compress.display(), format); 48 | ouch!("c", path_to_compress, compressed_file_path); 49 | 50 | let sniffed = infer::get_from_path(compressed_file_path) 51 | .expect("the file to be read") 52 | .expect("the MIME to be found"); 53 | 54 | assert_eq!(&sniffed.mime_type(), expected_mime); 55 | } 56 | } 57 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_compress_missing_extension.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output\", dir)" 4 | --- 5 | [ERROR] Cannot compress to 'output'. 6 | - You shall supply the compression format 7 | 8 | hint: Try adding supported extensions (see --help): 9 | hint: ouch compress ... output.tar.gz 10 | hint: ouch compress ... output.zip 11 | hint: 12 | hint: Alternatively, you can overwrite this option by using the '--format' flag: 13 | hint: ouch compress ... output --format tar.gz 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress a\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with missing extensions: /a 7 | - Decompression formats are detected automatically from file extension 8 | 9 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 10 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 11 | hint: 12 | hint: Alternatively, you can pass an extension to the '--format' flag: 13 | hint: ouch decompress /a --format tar.gz 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress a b.unknown\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with unsupported extensions: /b.unknown 7 | - Files with missing extensions: /a 8 | - Decompression formats are detected automatically from file extension 9 | 10 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 11 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 12 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_with_rar-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress b.unknown\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with unsupported extensions: /b.unknown 7 | - Decompression formats are detected automatically from file extension 8 | 9 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 10 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 11 | hint: 12 | hint: Alternatively, you can pass an extension to the '--format' flag: 13 | hint: ouch decompress /b.unknown --format tar.gz 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress a\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with missing extensions: /a 7 | - Decompression formats are detected automatically from file extension 8 | 9 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 10 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 11 | hint: 12 | hint: Alternatively, you can pass an extension to the '--format' flag: 13 | hint: ouch decompress /a --format tar.gz 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress a b.unknown\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with unsupported extensions: /b.unknown 7 | - Files with missing extensions: /a 8 | - Decompression formats are detected automatically from file extension 9 | 10 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 11 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 12 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_decompress_missing_extension_without_rar-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress b.unknown\", dir)" 4 | --- 5 | [ERROR] Cannot decompress files 6 | - Files with unsupported extensions: /b.unknown 7 | - Decompression formats are detected automatically from file extension 8 | 9 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 10 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 11 | hint: 12 | hint: Alternatively, you can pass an extension to the '--format' flag: 13 | hint: ouch decompress /b.unknown --format tar.gz 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_with_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format tar.gz.unknown\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format tar.gz.unknown` 6 | - Unsupported extension 'unknown' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_with_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format targz\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format targz` 6 | - Unsupported extension 'targz' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_with_rar-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format .tar.$#!@.rest\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format .tar.$#!@.rest` 6 | - Unsupported extension '$#!@' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, rar, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_without_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format tar.gz.unknown\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format tar.gz.unknown` 6 | - Unsupported extension 'unknown' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_without_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format targz\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format targz` 6 | - Unsupported extension 'targz' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_format_flag_without_rar-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output --format .tar.$#!@.rest\", dir)" 4 | --- 5 | [ERROR] Failed to parse `--format .tar.$#!@.rest` 6 | - Unsupported extension '$#!@' 7 | 8 | hint: Supported extensions are: tar, zip, bz, bz2, bz3, gz, lz4, xz, lzma, sz, zst, 7z 9 | hint: Supported aliases are: tgz, tbz, tlz4, txz, tzlma, tsz, tzst 10 | hint: 11 | hint: Examples: 12 | hint: --format tar 13 | hint: --format gz 14 | hint: --format tar.gz 15 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_missing_files-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress a b\", dir)" 4 | --- 5 | [ERROR] failed to canonicalize path `a` 6 | - File not found 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_missing_files-3.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch list a b\", dir)" 4 | --- 5 | [ERROR] failed to canonicalize path `a` 6 | - File not found 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_err_missing_files.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress a b\", dir)" 4 | --- 5 | [ERROR] failed to canonicalize path `a` 6 | - File not found 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_compress-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output.gz\", dir)" 4 | --- 5 | [INFO] Successfully compressed 'output.gz' 6 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_compress.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output.zip\", dir)" 4 | --- 5 | [INFO] Compressing 'input' 6 | [INFO] Successfully compressed 'output.zip' 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_decompress.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch decompress output.zst\", dir)" 4 | --- 5 | [INFO] Successfully decompressed archive in current directory 6 | [INFO] Files unpacked: 1 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_decompress_multiple_files.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: stdout_lines 4 | --- 5 | { 6 | "", 7 | "[INFO] Files unpacked: 4", 8 | "[INFO] Successfully decompressed archive in /outputs", 9 | "[INFO] extracted ( 0.00 B) \"outputs/inputs\"", 10 | "[INFO] extracted ( 0.00 B) \"outputs/inputs/input\"", 11 | "[INFO] extracted ( 0.00 B) \"outputs/inputs/input2\"", 12 | "[INFO] extracted ( 0.00 B) \"outputs/inputs/input3\"", 13 | } 14 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_format_flag_with_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output1 --format tar.gz\", dir)" 4 | --- 5 | [INFO] Compressing 'input' 6 | [INFO] Successfully compressed 'output1' 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_format_flag_with_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output2 --format .tar.gz\", dir)" 4 | --- 5 | [INFO] Compressing 'input' 6 | [INFO] Successfully compressed 'output2' 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_format_flag_without_rar-1.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output1 --format tar.gz\", dir)" 4 | --- 5 | [INFO] Compressing 'input' 6 | [INFO] Successfully compressed 'output1' 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_ok_format_flag_without_rar-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "run_ouch(\"ouch compress input output2 --format .tar.gz\", dir)" 4 | --- 5 | [INFO] Compressing 'input' 6 | [INFO] Successfully compressed 'output2' 7 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_usage_help_flag-2.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "output_to_string(ouch!(\"-h\"))" 4 | snapshot_kind: text 5 | --- 6 | A command-line utility for easily compressing and decompressing files and directories. 7 | 8 | Usage: [OPTIONS] 9 | 10 | Commands: 11 | compress Compress one or more files into one output file [aliases: c] 12 | decompress Decompresses one or more files, optionally into another folder [aliases: d] 13 | list List contents of an archive [aliases: l, ls] 14 | help Print this message or the help of the given subcommand(s) 15 | 16 | Options: 17 | -y, --yes Skip [Y/n] questions, default to yes 18 | -n, --no Skip [Y/n] questions, default to no 19 | -A, --accessible Activate accessibility mode, reducing visual noise [env: ACCESSIBLE=] 20 | -H, --hidden Ignore hidden files 21 | -q, --quiet Silence output 22 | -g, --gitignore Ignore files matched by git's ignore files 23 | -f, --format Specify the format of the archive 24 | -p, --password Decompress or list with password 25 | -c, --threads Concurrent working threads 26 | -h, --help Print help (see more with '--help') 27 | -V, --version Print version 28 | -------------------------------------------------------------------------------- /tests/snapshots/ui__ui_test_usage_help_flag.snap: -------------------------------------------------------------------------------- 1 | --- 2 | source: tests/ui.rs 3 | expression: "output_to_string(ouch!(\"--help\"))" 4 | snapshot_kind: text 5 | --- 6 | A command-line utility for easily compressing and decompressing files and directories. 7 | 8 | Supported formats: tar, zip, gz, 7z, xz/lzma, bz/bz2, bz3, lz4, sz (Snappy), zst, rar and br. 9 | 10 | Repository: https://github.com/ouch-org/ouch 11 | 12 | Usage: [OPTIONS] 13 | 14 | Commands: 15 | compress Compress one or more files into one output file [aliases: c] 16 | decompress Decompresses one or more files, optionally into another folder [aliases: d] 17 | list List contents of an archive [aliases: l, ls] 18 | help Print this message or the help of the given subcommand(s) 19 | 20 | Options: 21 | -y, --yes 22 | Skip [Y/n] questions, default to yes 23 | 24 | -n, --no 25 | Skip [Y/n] questions, default to no 26 | 27 | -A, --accessible 28 | Activate accessibility mode, reducing visual noise 29 | 30 | [env: ACCESSIBLE=] 31 | 32 | -H, --hidden 33 | Ignore hidden files 34 | 35 | -q, --quiet 36 | Silence output 37 | 38 | -g, --gitignore 39 | Ignore files matched by git's ignore files 40 | 41 | -f, --format 42 | Specify the format of the archive 43 | 44 | -p, --password 45 | Decompress or list with password 46 | 47 | -c, --threads 48 | Concurrent working threads 49 | 50 | -h, --help 51 | Print help (see a summary with '-h') 52 | 53 | -V, --version 54 | Print version 55 | -------------------------------------------------------------------------------- /tests/ui.rs: -------------------------------------------------------------------------------- 1 | /// Snapshot tests for Ouch's output. 2 | /// 3 | /// See CONTRIBUTING.md for a brief guide on how to use [`insta`] for these tests. 4 | /// [`insta`]: https://docs.rs/insta 5 | #[macro_use] 6 | mod utils; 7 | 8 | use std::{collections::BTreeSet, ffi::OsStr, io, path::Path, process::Output}; 9 | 10 | use insta::assert_snapshot as ui; 11 | use regex::Regex; 12 | 13 | use crate::utils::create_files_in; 14 | 15 | fn testdir() -> io::Result<(tempfile::TempDir, &'static Path)> { 16 | let dir = tempfile::tempdir()?; 17 | let path = dir.path().to_path_buf().into_boxed_path(); 18 | Ok((dir, Box::leak(path))) 19 | } 20 | 21 | fn run_ouch(argv: &str, dir: &Path) -> String { 22 | let output = utils::cargo_bin() 23 | .args(argv.split_whitespace().skip(1)) 24 | .current_dir(dir) 25 | .output() 26 | .unwrap_or_else(|err| { 27 | panic!( 28 | "Failed to run command\n\ 29 | argv: {argv}\n\ 30 | path: {dir:?}\n\ 31 | err: {err}" 32 | ) 33 | }); 34 | 35 | redact_paths(&output_to_string(output), dir) 36 | } 37 | 38 | /// Remove random tempdir paths from snapshots to make them deterministic. 39 | fn redact_paths(text: &str, dir: &Path) -> String { 40 | let dir_name = dir.file_name().and_then(OsStr::to_str).unwrap(); 41 | 42 | // this regex should be good as long as the path does not contain whitespace characters 43 | let re = Regex::new(&format!(r"\S*[/\\]{dir_name}[/\\]")).unwrap(); 44 | re.replace_all(text, "/").into() 45 | } 46 | 47 | fn output_to_string(output: Output) -> String { 48 | String::from_utf8(output.stdout).unwrap() + std::str::from_utf8(&output.stderr).unwrap() 49 | } 50 | 51 | #[test] 52 | fn ui_test_err_compress_missing_extension() { 53 | let (_dropper, dir) = testdir().unwrap(); 54 | 55 | // prepare 56 | create_files_in(dir, &["input"]); 57 | 58 | ui!(run_ouch("ouch compress input output", dir)); 59 | } 60 | 61 | #[test] 62 | fn ui_test_err_decompress_missing_extension() { 63 | let (_dropper, dir) = testdir().unwrap(); 64 | 65 | create_files_in(dir, &["a", "b.unknown"]); 66 | 67 | let snapshot = concat_snapshot_filename_rar_feature("ui_test_err_decompress_missing_extension"); 68 | ui!(format!("{snapshot}-1"), run_ouch("ouch decompress a", dir)); 69 | ui!(format!("{snapshot}-2"), run_ouch("ouch decompress a b.unknown", dir)); 70 | ui!(format!("{snapshot}-3"), run_ouch("ouch decompress b.unknown", dir)); 71 | } 72 | 73 | #[test] 74 | fn ui_test_err_missing_files() { 75 | let (_dropper, dir) = testdir().unwrap(); 76 | 77 | ui!(run_ouch("ouch compress a b", dir)); 78 | ui!(run_ouch("ouch decompress a b", dir)); 79 | ui!(run_ouch("ouch list a b", dir)); 80 | } 81 | 82 | #[test] 83 | fn ui_test_err_format_flag() { 84 | let (_dropper, dir) = testdir().unwrap(); 85 | 86 | // prepare 87 | create_files_in(dir, &["input"]); 88 | 89 | let snapshot = concat_snapshot_filename_rar_feature("ui_test_err_format_flag"); 90 | ui!( 91 | format!("{snapshot}-1"), 92 | run_ouch("ouch compress input output --format tar.gz.unknown", dir), 93 | ); 94 | ui!( 95 | format!("{snapshot}-2"), 96 | run_ouch("ouch compress input output --format targz", dir), 97 | ); 98 | ui!( 99 | format!("{snapshot}-3"), 100 | run_ouch("ouch compress input output --format .tar.$#!@.rest", dir), 101 | ); 102 | } 103 | 104 | #[test] 105 | fn ui_test_ok_format_flag() { 106 | let (_dropper, dir) = testdir().unwrap(); 107 | 108 | // prepare 109 | create_files_in(dir, &["input"]); 110 | 111 | let snapshot = concat_snapshot_filename_rar_feature("ui_test_ok_format_flag"); 112 | ui!( 113 | format!("{snapshot}-1"), 114 | run_ouch("ouch compress input output1 --format tar.gz", dir), 115 | ); 116 | ui!( 117 | format!("{snapshot}-2"), 118 | run_ouch("ouch compress input output2 --format .tar.gz", dir), 119 | ); 120 | } 121 | 122 | #[test] 123 | fn ui_test_ok_compress() { 124 | let (_dropper, dir) = testdir().unwrap(); 125 | 126 | // prepare 127 | create_files_in(dir, &["input"]); 128 | 129 | ui!(run_ouch("ouch compress input output.zip", dir)); 130 | ui!(run_ouch("ouch compress input output.gz", dir)); 131 | } 132 | 133 | #[test] 134 | fn ui_test_ok_decompress() { 135 | let (_dropper, dir) = testdir().unwrap(); 136 | 137 | // prepare 138 | create_files_in(dir, &["input"]); 139 | run_ouch("ouch compress input output.zst", dir); 140 | 141 | ui!(run_ouch("ouch decompress output.zst", dir)); 142 | } 143 | 144 | #[cfg(target_os = "linux")] 145 | #[test] 146 | fn ui_test_ok_decompress_multiple_files() { 147 | let (_dropper, dir) = testdir().unwrap(); 148 | 149 | let inputs_dir = dir.join("inputs"); 150 | std::fs::create_dir(&inputs_dir).unwrap(); 151 | 152 | let outputs_dir = dir.join("outputs"); 153 | std::fs::create_dir(&outputs_dir).unwrap(); 154 | 155 | // prepare 156 | create_files_in(&inputs_dir, &["input", "input2", "input3"]); 157 | 158 | let compress_command = format!("ouch compress {} output.tar.zst", inputs_dir.to_str().unwrap()); 159 | run_ouch(&compress_command, dir); 160 | 161 | let decompress_command = format!("ouch decompress output.tar.zst --dir {}", outputs_dir.to_str().unwrap()); 162 | let stdout = run_ouch(&decompress_command, dir); 163 | let stdout_lines = stdout.split('\n').collect::>(); 164 | insta::assert_debug_snapshot!(stdout_lines); 165 | } 166 | 167 | #[test] 168 | fn ui_test_usage_help_flag() { 169 | insta::with_settings!({filters => vec![ 170 | // binary name is `ouch.exe` on Windows and `ouch` on everywhere else 171 | (r"(Usage:.*\b)ouch(\.exe)?\b", "${1}"), 172 | ]}, { 173 | ui!(output_to_string(ouch!("--help"))); 174 | ui!(output_to_string(ouch!("-h"))); 175 | }); 176 | } 177 | 178 | /// Concatenates `with_rar` or `without_rar` if the feature is toggled or not. 179 | fn concat_snapshot_filename_rar_feature(name: &str) -> String { 180 | let suffix = if cfg!(feature = "unrar") { 181 | "with_rar" 182 | } else { 183 | "without_rar" 184 | }; 185 | 186 | format!("{name}_{suffix}") 187 | } 188 | -------------------------------------------------------------------------------- /tests/utils.rs: -------------------------------------------------------------------------------- 1 | // This warning is unavoidable when reusing testing utils. 2 | #![allow(dead_code)] 3 | 4 | use std::{ 5 | env, 6 | io::Write, 7 | path::{Path, PathBuf}, 8 | }; 9 | 10 | use assert_cmd::Command; 11 | use fs_err as fs; 12 | use rand::{Rng, RngCore}; 13 | 14 | /// Run ouch with the provided arguments, returns [`assert_cmd::Output`] 15 | #[macro_export] 16 | macro_rules! ouch { 17 | ($($e:expr),*) => { 18 | $crate::utils::cargo_bin() 19 | $(.arg($e))* 20 | .arg("--yes") 21 | .unwrap() 22 | } 23 | } 24 | 25 | pub fn cargo_bin() -> Command { 26 | env::vars() 27 | .find_map(|(k, v)| { 28 | (k.starts_with("CARGO_TARGET_") && k.ends_with("_RUNNER")).then(|| { 29 | let mut runner = v.split_whitespace(); 30 | let mut cmd = Command::new(runner.next().unwrap()); 31 | cmd.args(runner).arg(assert_cmd::cargo::cargo_bin("ouch")); 32 | cmd 33 | }) 34 | }) 35 | .unwrap_or_else(|| Command::cargo_bin("ouch").expect("Failed to find ouch executable")) 36 | } 37 | 38 | /// Creates files in the specified directory. 39 | /// 40 | /// ## Example 41 | /// 42 | /// ```no_run 43 | /// let (_dropper, dir) = testdir().unwrap(); 44 | /// create_files_in(dir, &["file1.txt", "file2.txt"]); 45 | /// ``` 46 | pub fn create_files_in(dir: &Path, files: &[&str]) { 47 | for f in files { 48 | std::fs::File::create(dir.join(f)).unwrap(); 49 | } 50 | } 51 | 52 | /// Write random content to a file 53 | pub fn write_random_content(file: &mut impl Write, rng: &mut impl RngCore) { 54 | let mut data = vec![0; rng.gen_range(0..8192)]; 55 | 56 | rng.fill_bytes(&mut data); 57 | file.write_all(&data).unwrap(); 58 | } 59 | 60 | /// Check that two directories have the exact same content recursively. 61 | /// Checks equility of file types if preserve_permissions is true, ignored on non-unix 62 | // Silence clippy warning that triggers because of the `#[cfg(unix)]` on Windows. 63 | #[allow(clippy::only_used_in_recursion)] 64 | pub fn assert_same_directory(x: impl Into, y: impl Into, preserve_permissions: bool) { 65 | fn read_dir(dir: impl Into) -> impl Iterator { 66 | let mut dir: Vec<_> = fs::read_dir(dir).unwrap().map(|entry| entry.unwrap()).collect(); 67 | dir.sort_by_key(|x| x.file_name()); 68 | dir.into_iter() 69 | } 70 | 71 | let mut x = read_dir(x); 72 | let mut y = read_dir(y); 73 | 74 | loop { 75 | match (x.next(), y.next()) { 76 | (Some(x), Some(y)) => { 77 | assert_eq!(x.file_name(), y.file_name()); 78 | 79 | let meta_x = x.metadata().unwrap(); 80 | let meta_y = y.metadata().unwrap(); 81 | let ft_x = meta_x.file_type(); 82 | let ft_y = meta_y.file_type(); 83 | 84 | #[cfg(unix)] 85 | if preserve_permissions { 86 | assert_eq!(ft_x, ft_y); 87 | } 88 | 89 | if ft_x.is_dir() && ft_y.is_dir() { 90 | assert_same_directory(x.path(), y.path(), preserve_permissions); 91 | } else if (ft_x.is_file() && ft_y.is_file()) || (ft_x.is_symlink() && ft_y.is_symlink()) { 92 | assert_eq!(meta_x.len(), meta_y.len()); 93 | assert_eq!(fs::read(x.path()).unwrap(), fs::read(y.path()).unwrap()); 94 | } else { 95 | panic!( 96 | "entries should be both directories or both files\n left: `{:?}`,\n right: `{:?}`", 97 | x.path(), 98 | y.path() 99 | ); 100 | } 101 | } 102 | 103 | (None, None) => break, 104 | 105 | (x, y) => { 106 | panic!( 107 | "directories don't have the same number of entries\n left: `{:?}`,\n right: `{:?}`", 108 | x.map(|x| x.path()), 109 | y.map(|y| y.path()), 110 | ) 111 | } 112 | } 113 | } 114 | } 115 | 116 | #[test] 117 | fn src_is_src() { 118 | assert_same_directory("src", "src", true); 119 | } 120 | 121 | #[test] 122 | #[should_panic] 123 | fn src_is_not_tests() { 124 | assert_same_directory("src", "tests", false); 125 | } 126 | --------------------------------------------------------------------------------