├── .github └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── CHANGELOG.md ├── Cargo.lock ├── Cargo.toml ├── FAQ.md ├── LICENSE ├── README.md ├── RELEASE-CHECKLIST.md ├── assets ├── README.md ├── syntax │ ├── basic │ │ └── json.sublime-syntax │ └── large │ │ ├── css.sublime-syntax │ │ ├── html.sublime-syntax │ │ ├── js.sublime-syntax │ │ └── xml.sublime-syntax ├── themes │ ├── ansi.tmTheme │ ├── fruity.tmTheme │ ├── monokai.tmTheme │ └── solarized.tmTheme ├── xh-demo.gif ├── xhs └── xhs.1.gz ├── build.rs ├── completions ├── _xh ├── _xh.ps1 ├── xh.bash ├── xh.elv ├── xh.fish └── xh.nu ├── doc ├── man-template.roff └── xh.1 ├── install.ps1 ├── install.sh ├── src ├── auth.rs ├── buffer.rs ├── cli.rs ├── content_disposition.rs ├── decoder.rs ├── download.rs ├── error_reporting.rs ├── formatting │ ├── headers.rs │ ├── mod.rs │ └── palette.rs ├── generation.rs ├── main.rs ├── middleware.rs ├── nested_json.rs ├── netrc.rs ├── printer.rs ├── redacted.rs ├── redirect.rs ├── request_items.rs ├── session.rs ├── to_curl.rs └── utils.rs └── tests ├── cases ├── compress_request_body.rs ├── download.rs ├── logging.rs └── mod.rs ├── cli.rs ├── fixtures ├── certs │ ├── README.md │ ├── client.badssl.com.crt │ ├── client.badssl.com.key │ └── wildcard-self-signed.pem └── responses │ ├── README.md │ ├── hello_world.br │ ├── hello_world.gz │ ├── hello_world.zst │ └── hello_world.zz └── server └── mod.rs /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI 2 | 3 | on: 4 | pull_request: 5 | push: 6 | branches: [ master ] 7 | schedule: 8 | - cron: '00 00 * * *' 9 | 10 | jobs: 11 | test: 12 | name: Test 13 | runs-on: ${{ matrix.job.os }} 14 | timeout-minutes: 15 15 | strategy: 16 | matrix: 17 | job: 18 | - target: x86_64-unknown-linux-gnu 19 | os: ubuntu-latest 20 | flags: --features=native-tls 21 | - target: x86_64-unknown-linux-gnu 22 | os: ubuntu-latest 23 | flags: --no-default-features --features=native-tls,online-tests # disables rustls 24 | - target: x86_64-apple-darwin 25 | os: macos-13 26 | flags: --features=native-tls 27 | - target: aarch64-apple-darwin 28 | os: macos-14 29 | flags: --features=native-tls 30 | - target: x86_64-pc-windows-msvc 31 | os: windows-latest 32 | flags: --features=native-tls 33 | - target: x86_64-unknown-linux-musl 34 | os: ubuntu-latest 35 | use-cross: true 36 | - target: aarch64-unknown-linux-musl 37 | os: ubuntu-latest 38 | use-cross: true 39 | flags: -- --test-threads=4 40 | - target: arm-unknown-linux-gnueabihf 41 | os: ubuntu-latest 42 | use-cross: true 43 | flags: -- --test-threads=4 44 | steps: 45 | - uses: actions/checkout@v4 46 | 47 | - uses: dtolnay/rust-toolchain@master 48 | with: 49 | toolchain: 1.74.0 # minimum supported rust version 50 | targets: ${{ matrix.job.target }} 51 | 52 | - uses: Swatinem/rust-cache@v2 53 | with: 54 | key: v2-${{ matrix.job.target }} 55 | 56 | - uses: ClementTsang/cargo-action@v0.0.6 57 | with: 58 | use-cross: ${{ !!matrix.job.use-cross }} 59 | command: test 60 | args: --target ${{ matrix.job.target }} ${{ matrix.job.flags }} 61 | 62 | fmt-and-clippy: 63 | name: Rustfmt and clippy 64 | runs-on: ubuntu-latest 65 | steps: 66 | - uses: actions/checkout@v4 67 | 68 | - uses: dtolnay/rust-toolchain@master 69 | with: 70 | toolchain: stable 71 | components: rustfmt, clippy 72 | 73 | - uses: Swatinem/rust-cache@v2 74 | 75 | - name: Rustfmt 76 | run: cargo fmt --check 77 | 78 | - name: Clippy (default features) 79 | run: cargo clippy --tests -- -D warnings -A unknown-lints 80 | 81 | - name: Clippy (all features) 82 | run: cargo clippy --all-features --tests -- -D warnings -A unknown-lints 83 | 84 | - name: Clippy (native-tls only) 85 | run: cargo clippy --no-default-features --features=native-tls,online-tests --tests -- -D warnings -A unknown-lints 86 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: release 2 | 3 | on: 4 | push: 5 | tags: [ v*.*.* ] 6 | 7 | jobs: 8 | test: 9 | name: Test 10 | runs-on: ${{ matrix.job.os }} 11 | timeout-minutes: 15 12 | strategy: 13 | matrix: 14 | job: 15 | - target: x86_64-unknown-linux-gnu 16 | os: ubuntu-latest 17 | flags: --features=native-tls 18 | - target: x86_64-unknown-linux-gnu 19 | os: ubuntu-latest 20 | flags: --no-default-features --features=native-tls,online-tests # disables rustls 21 | - target: x86_64-apple-darwin 22 | os: macos-13 23 | flags: --features=native-tls 24 | - target: aarch64-apple-darwin 25 | os: macos-14 26 | flags: --features=native-tls 27 | - target: x86_64-pc-windows-msvc 28 | os: windows-latest 29 | flags: --features=native-tls 30 | - target: x86_64-unknown-linux-musl 31 | os: ubuntu-latest 32 | use-cross: true 33 | - target: aarch64-unknown-linux-musl 34 | os: ubuntu-latest 35 | use-cross: true 36 | flags: -- --test-threads=4 37 | - target: arm-unknown-linux-gnueabihf 38 | os: ubuntu-latest 39 | use-cross: true 40 | flags: -- --test-threads=4 41 | steps: 42 | - uses: actions/checkout@v4 43 | 44 | - uses: dtolnay/rust-toolchain@master 45 | with: 46 | toolchain: 1.74.0 # minimum supported rust version 47 | targets: ${{ matrix.job.target }} 48 | 49 | - uses: ClementTsang/cargo-action@v0.0.6 50 | with: 51 | use-cross: ${{ !!matrix.job.use-cross }} 52 | command: test 53 | args: --target ${{ matrix.job.target }} ${{ matrix.job.flags }} 54 | 55 | deploy: 56 | name: Deploy 57 | needs: [ test ] 58 | runs-on: ${{ matrix.job.os }} 59 | strategy: 60 | matrix: 61 | job: 62 | - os: ubuntu-latest 63 | target: aarch64-unknown-linux-musl 64 | binutils: aarch64-linux-gnu 65 | use-cross: true 66 | - os: ubuntu-latest 67 | target: arm-unknown-linux-gnueabihf 68 | binutils: arm-linux-gnueabihf 69 | use-cross: true 70 | - os: ubuntu-latest 71 | target: x86_64-unknown-linux-musl 72 | use-cross: true 73 | - os: macos-13 74 | target: x86_64-apple-darwin 75 | flags: --features=native-tls 76 | - os: macos-14 77 | target: aarch64-apple-darwin 78 | flags: --features=native-tls 79 | - os: windows-latest 80 | target: x86_64-pc-windows-msvc 81 | flags: --features=native-tls 82 | rustflags: -C target-feature=+crt-static 83 | steps: 84 | - uses: actions/checkout@v4 85 | 86 | - name: Set RUSTFLAGS env variable 87 | if: matrix.job.rustflags 88 | shell: bash 89 | run: echo "RUSTFLAGS=${{ matrix.job.rustflags }}" >> $GITHUB_ENV 90 | 91 | - name: Build target 92 | uses: ClementTsang/cargo-action@v0.0.6 93 | with: 94 | use-cross: ${{ !!matrix.job.use-cross }} 95 | command: build 96 | args: --release --target ${{ matrix.job.target }} ${{ matrix.job.flags }} 97 | env: 98 | CARGO_PROFILE_RELEASE_LTO: true 99 | 100 | - name: Strip release binary (linux and macOS) 101 | if: matrix.job.os != 'windows-latest' 102 | run: | 103 | if [ "${{ matrix.job.binutils }}" != "" ]; then 104 | sudo apt -y install "binutils-${{ matrix.job.binutils }}" 105 | "${{ matrix.job.binutils }}-strip" "target/${{ matrix.job.target }}/release/xh" 106 | else 107 | strip "target/${{ matrix.job.target }}/release/xh" 108 | fi 109 | 110 | - name: Package 111 | shell: bash 112 | run: | 113 | if [ "${{ matrix.job.os }}" = "windows-latest" ]; then 114 | bin="target/${{ matrix.job.target }}/release/xh.exe" 115 | else 116 | bin="target/${{ matrix.job.target }}/release/xh" 117 | fi 118 | staging="xh-${{ github.ref_name }}-${{ matrix.job.target }}" 119 | 120 | mkdir -p "$staging"/{doc,completions} 121 | cp LICENSE README.md $bin $staging 122 | cp CHANGELOG.md doc/xh.1 "$staging"/doc 123 | cp completions/* "$staging"/completions 124 | 125 | if [ "${{ matrix.job.os }}" = "windows-latest" ]; then 126 | 7z a "$staging.zip" $staging 127 | elif [[ "${{ matrix.job.os }}" =~ "macos" ]]; then 128 | gtar czvf "$staging.tar.gz" $staging 129 | else 130 | tar czvf "$staging.tar.gz" $staging 131 | fi 132 | 133 | - name: Package (debian) 134 | if: matrix.job.target == 'x86_64-unknown-linux-musl' 135 | shell: bash 136 | run: | 137 | cargo install --git https://github.com/blyxxyz/cargo-deb --locked --branch xh-patches cargo-deb 138 | cargo deb --no-build --target ${{ matrix.job.target }} 139 | cp "target/${{ matrix.job.target }}/debian"/*.deb ./ 140 | 141 | - name: Publish 142 | uses: softprops/action-gh-release@v1 143 | with: 144 | files: 'xh*' 145 | draft: true 146 | env: 147 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 148 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.direnv 2 | /.envrc 3 | /target 4 | /.vscode 5 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "xh" 3 | version = "0.24.1" 4 | authors = ["ducaale "] 5 | edition = "2021" 6 | rust-version = "1.74.0" 7 | license = "MIT" 8 | description = "Friendly and fast tool for sending HTTP requests" 9 | documentation = "https://github.com/ducaale/xh" 10 | homepage = "https://github.com/ducaale/xh" 11 | repository = "https://github.com/ducaale/xh" 12 | readme = "README.md" 13 | keywords = ["http"] 14 | categories = ["command-line-utilities"] 15 | exclude = ["assets/xhs", "assets/xhs.1.gz"] 16 | 17 | [dependencies] 18 | anyhow = "1.0.38" 19 | brotli = { version = "3.3.0", default-features = false, features = ["std"] } 20 | chardetng = "0.1.15" 21 | clap = { version = "4.4", features = ["derive", "wrap_help", "string"] } 22 | clap_complete = "4.4" 23 | clap_complete_nushell = "4.4" 24 | cookie_store = { version = "0.21.1", features = ["preserve_order"] } 25 | digest_auth = "0.3.0" 26 | dirs = "5.0" 27 | encoding_rs = "0.8.28" 28 | encoding_rs_io = "0.1.7" 29 | flate2 = "1.0.22" 30 | # Add "tracing" feature to hyper once it stabilizes 31 | hyper = { version = "1.2", default-features = false } 32 | indicatif = "0.17" 33 | jsonxf = "1.1.0" 34 | memchr = "2.4.1" 35 | mime = "0.3.16" 36 | mime2ext = "0.1.0" 37 | mime_guess = "2.0" 38 | once_cell = "1.8.0" 39 | os_display = "0.1.3" 40 | pem = "3.0" 41 | regex-lite = "0.1.5" 42 | roff = "0.2.1" 43 | rpassword = "7.2.0" 44 | serde = { version = "1.0", features = ["derive"] } 45 | serde-transcode = "1.1.1" 46 | serde_json = { version = "1.0", features = ["preserve_order"] } 47 | serde_urlencoded = "0.7.0" 48 | supports-hyperlinks = "3.0.0" 49 | termcolor = "1.1.2" 50 | time = "0.3.16" 51 | humantime = "2.2.0" 52 | unicode-width = "0.1.9" 53 | url = "2.2.2" 54 | ruzstd = { version = "0.7", default-features = false, features = ["std"]} 55 | env_logger = { version = "0.11.3", default-features = false, features = ["color", "auto-color", "humantime"] } 56 | log = "0.4.21" 57 | 58 | # Enable logging in transitive dependencies. 59 | # The rustls version number should be kept in sync with hyper/reqwest. 60 | rustls = { version = "0.23.25", optional = true, default-features = false, features = ["logging"] } 61 | tracing = { version = "0.1.41", default-features = false, features = ["log"] } 62 | reqwest_cookie_store = { version = "0.8.0", features = ["serde"] } 63 | percent-encoding = "2.3.1" 64 | sanitize-filename = "0.6.0" 65 | 66 | [dependencies.reqwest] 67 | version = "0.12.3" 68 | default-features = false 69 | features = ["json", "multipart", "blocking", "socks", "cookies", "http2", "macos-system-configuration"] 70 | 71 | [dependencies.syntect] 72 | version = "5.1" 73 | default-features = false 74 | features = ["parsing", "dump-load", "regex-onig"] 75 | 76 | [target.'cfg(not(any(target_os = "android", target_os = "fuchsia", target_os = "linux")))'.dependencies] 77 | network-interface = { version = "1.0.0", optional = true } 78 | 79 | [build-dependencies.syntect] 80 | version = "5.1" 81 | default-features = false 82 | features = ["dump-create", "plist-load", "regex-onig", "yaml-load"] 83 | 84 | [dev-dependencies] 85 | assert_cmd = "2.0.8" 86 | form_urlencoded = "1.0.1" 87 | indoc = "2.0" 88 | rand = "0.8.3" 89 | predicates = "3.0" 90 | hyper = { version = "1.2", features = ["server"] } 91 | tokio = { version = "1", features = ["rt", "sync", "time"] } 92 | tempfile = "3.2.0" 93 | hyper-util = { version = "0.1.3", features = ["server"] } 94 | http-body-util = "0.1.1" 95 | 96 | [features] 97 | default = ["online-tests", "rustls", "network-interface"] 98 | native-tls = ["reqwest/native-tls", "reqwest/native-tls-alpn"] 99 | rustls = ["reqwest/rustls-tls", "reqwest/rustls-tls-webpki-roots", "reqwest/rustls-tls-native-roots", "dep:rustls"] 100 | 101 | # To be used by platforms that don't support binding to interface via SO_BINDTODEVICE 102 | # Ideally, this would be auto-disabled on platforms that don't need it 103 | # However: https://github.com/rust-lang/cargo/issues/1197 104 | # Also, see https://github.com/ducaale/xh/issues/330 105 | network-interface = ["dep:network-interface"] 106 | 107 | online-tests = [] 108 | ipv6-tests = [] 109 | 110 | [package.metadata.cross.build.env] 111 | passthrough = ["CARGO_PROFILE_RELEASE_LTO"] 112 | 113 | [package.metadata.deb] 114 | features = [] 115 | section = "web" 116 | license-file = "LICENSE" 117 | preserve-symlinks = true 118 | assets = [ 119 | ["target/release/xh", "usr/bin/", "755"], 120 | ["assets/xhs", "usr/bin/", "777"], 121 | ["CHANGELOG.md", "usr/share/doc/xh/NEWS", "644"], 122 | ["README.md", "usr/share/doc/xh/README", "644"], 123 | ["doc/xh.1", "usr/share/man/man1/xh.1", "644"], 124 | ["assets/xhs.1.gz", "usr/share/man/man1/xhs.1.gz", "777"], 125 | ["completions/xh.bash", "usr/share/bash-completion/completions/xh", "644"], 126 | ["completions/xh.fish", "usr/share/fish/vendor_completions.d/xh.fish", "644"], 127 | ["completions/_xh", "usr/share/zsh/vendor-completions/", "644"], 128 | ] 129 | extended-description = """\ 130 | xh is a friendly and fast tool for sending HTTP requests. 131 | It reimplements as much as possible of HTTPie's excellent design, with a focus 132 | on improved performance. 133 | """ 134 | -------------------------------------------------------------------------------- /FAQ.md: -------------------------------------------------------------------------------- 1 |

Why do some HTTP headers show up mangled?

2 | 3 | HTTP header values are officially only supposed to contain ASCII. Other bytes are "opaque data": 4 | 5 | > Historically, HTTP has allowed field content with text in the ISO-8859-1 charset [[ISO-8859-1](https://datatracker.ietf.org/doc/html/rfc7230#ref-ISO-8859-1)], supporting other charsets only through use of [[RFC2047](https://datatracker.ietf.org/doc/html/rfc2047)] encoding. In practice, most HTTP header field values use only a subset of the US-ASCII charset [[USASCII](https://datatracker.ietf.org/doc/html/rfc7230#ref-USASCII)]. Newly defined header fields SHOULD limit their field values to US-ASCII octets. A recipient SHOULD treat other octets in field content (obs-text) as opaque data. 6 | 7 | ([RFC 7230](https://datatracker.ietf.org/doc/html/rfc7230#section-3.2.4)) 8 | 9 | In practice some headers are for some purposes treated like UTF-8, which supports all languages and characters in Unicode. But if you try to access header values through a browser's `fetch()` API or view them in the developer tools then they tend to be decoded as ISO-8859-1, which only supports a very limited number of characters and may not be the actual intended encoding. 10 | 11 | xh as of version 0.23.0 shows the ISO-8859-1 decoding by default to avoid a confusing difference with web browsers. If the value looks like valid UTF-8 then it additionally shows the UTF-8 decoding. 12 | 13 | That is, the following request: 14 | ```console 15 | xh -v https://example.org Smile:☺ 16 | ``` 17 | Displays the `Smile` header like this: 18 | ``` 19 | Smile: â�º (UTF-8: ☺) 20 | ``` 21 | The server will probably see `â�º` instead of the smiley. Or it might see `☺` after all. It depends! 22 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Mohamed Daahir 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 | -------------------------------------------------------------------------------- /RELEASE-CHECKLIST.md: -------------------------------------------------------------------------------- 1 | ## Release Checklist 2 | 3 | - Update `README.md`'s Usage section with the output of `xh --help` 4 | - Update `CHANGELOG.md` (rename unreleased header to the current date, add any missing changes). 5 | - Run `cargo update` to update dependencies. 6 | - Bump up the version in `Cargo.toml` and run `cargo check` to update `Cargo.lock`. 7 | - Run the following to update shell-completion files and man pages. 8 | ```sh 9 | cargo run --all-features -- --generate complete-bash > completions/xh.bash 10 | cargo run --all-features -- --generate complete-elvish > completions/xh.elv 11 | cargo run --all-features -- --generate complete-fish > completions/xh.fish 12 | cargo run --all-features -- --generate complete-nushell > completions/xh.nu 13 | cargo run --all-features -- --generate complete-powershell > completions/_xh.ps1 14 | cargo run --all-features -- --generate complete-zsh > completions/_xh 15 | cargo run --all-features -- --generate man > doc/xh.1 16 | ``` 17 | - Commit changes and push them to remote. 18 | - Add git tag e.g `git tag v0.9.0`. 19 | - Push the local tags to remote i.e `git push --tags` which will start the CI release action. 20 | - Publish to crates.io by running `cargo publish`. 21 | -------------------------------------------------------------------------------- /assets/README.md: -------------------------------------------------------------------------------- 1 | ## Syntaxes and themes used 2 | - [Sublime-HTTP](https://github.com/samsalisbury/Sublime-HTTP) 3 | - [json-kv](https://github.com/aurule/json-kv) 4 | - [Sublime Packages](https://github.com/sublimehq/Packages/tree/fa6b8629c95041bf262d4c1dab95c456a0530122) 5 | - [ansi-dark theme](https://github.com/sharkdp/bat/blob/master/assets/themes/ansi-dark.tmTheme) 6 | - Solarized and Monokai are based on ansi-dark with color values taken from the [pygments](https://github.com/pygments/pygments) library 7 | - [Solarized](https://github.com/pygments/pygments/blob/master/pygments/styles/solarized.py) 8 | - [Monokai](https://github.com/pygments/pygments/blob/master/pygments/styles/monokai.py) 9 | - [Fruity](https://github.com/pygments/pygments/blob/master/pygments/styles/fruity.py) 10 | 11 | ## Tools used to create xh-demo.gif 12 | - [asciinema](https://github.com/asciinema/asciinema) for the initial recording. 13 | - [asciinema-edit](https://github.com/cirocosta/asciinema-edit) to speed up the recording. 14 | - [asciicast2gif](https://github.com/asciinema/asciicast2gif) to produce the final GIF. The 15 | default font didn't look great so I modified it to use [Cascadia Mono](https://github.com/microsoft/cascadia-code). 16 | -------------------------------------------------------------------------------- /assets/syntax/basic/json.sublime-syntax: -------------------------------------------------------------------------------- 1 | %YAML 1.2 2 | --- 3 | # http://www.sublimetext.com/docs/3/syntax.html 4 | name: JSON Key-Value 5 | file_extensions: 6 | - json 7 | scope: source.json 8 | contexts: 9 | main: 10 | - match: //.* 11 | comment: Single-line comment 12 | scope: comment.single.line.jsonkv 13 | - match: /\* 14 | comment: Multi-line comment 15 | push: 16 | - meta_scope: comment.block.jsonkv 17 | - match: \*/ 18 | pop: true 19 | - match: '(")(?i)([^\\"]+)(")\s*?:' 20 | comment: Key names 21 | captures: 22 | 1: keyword.other.name.jsonkv.start 23 | 2: keyword.other.name.jsonkv 24 | 3: keyword.other.name.jsonkv.end 25 | - match: '"' 26 | comment: String values 27 | push: 28 | - meta_scope: string.quoted.jsonkv 29 | - match: '"' 30 | pop: true 31 | - match: '\\[tnr"]' 32 | comment: Escape characters 33 | scope: constant.character.escape.jsonkv 34 | - match: \d+(?:.\d+)? 35 | comment: Numeric values 36 | scope: constant.numeric.jsonkv 37 | - match: true|false 38 | comment: Boolean values 39 | scope: constant.language.boolean.jsonkv 40 | - match: "null" 41 | comment: Null value 42 | scope: constant.language.null.jsonkv 43 | -------------------------------------------------------------------------------- /assets/syntax/large/xml.sublime-syntax: -------------------------------------------------------------------------------- 1 | %YAML 1.2 2 | --- 3 | name: XML 4 | file_extensions: 5 | - xml 6 | - xsd 7 | - xslt 8 | - tld 9 | - dtml 10 | - rss 11 | - opml 12 | - svg 13 | first_line_match: |- 14 | (?x) 15 | ^(?: 16 | <\?xml\s 17 | | \s*<([\w-]+):Envelope\s+xmlns:\1\s*=\s*"http://schemas.xmlsoap.org/soap/envelope/"\s*> 18 | ) 19 | scope: text.xml 20 | variables: 21 | # This is the full XML Name production, but should not be used where namespaces 22 | # are possible. Those locations should use a qualified_name. 23 | name: '[[:alpha:]:_][[:alnum:]:_.-]*' 24 | # This is the form that allows a namespace prefix (ns:) followed by a local 25 | # name. The captures are: 26 | # 1: namespace prefix name 27 | # 2: namespace prefix colon 28 | # 3: local tag name 29 | qualified_name: '(?:([[:alpha:]_][[:alnum:]_.-]*)(:))?([[:alpha:]_][[:alnum:]_.-]*)' 30 | 31 | contexts: 32 | main: 33 | - match: '(<\?)(xml)(?=\s)' 34 | captures: 35 | 1: punctuation.definition.tag.begin.xml 36 | 2: entity.name.tag.xml 37 | push: 38 | - meta_scope: meta.tag.preprocessor.xml 39 | - match: \?> 40 | scope: punctuation.definition.tag.end.xml 41 | pop: true 42 | - match: '\s+{{qualified_name}}(=)?' 43 | captures: 44 | 1: entity.other.attribute-name.namespace.xml 45 | 2: entity.other.attribute-name.xml punctuation.separator.namespace.xml 46 | 3: entity.other.attribute-name.localname.xml 47 | 4: punctuation.separator.key-value.xml 48 | - include: double-quoted-string 49 | - include: single-quoted-string 50 | - match: '() 58 | captures: 59 | 1: punctuation.definition.tag.end.xml 60 | pop: true 61 | - include: internal-subset 62 | - include: comment 63 | - match: '(\s]*)' 64 | captures: 65 | 1: punctuation.definition.tag.begin.xml 66 | 2: entity.name.tag.namespace.xml 67 | 3: entity.name.tag.xml punctuation.separator.namespace.xml 68 | 4: entity.name.tag.localname.xml 69 | 5: invalid.illegal.bad-tag-name.xml 70 | push: 71 | - meta_scope: meta.tag.xml 72 | - match: /?> 73 | scope: punctuation.definition.tag.end.xml 74 | pop: true 75 | - include: tag-stuff 76 | - match: '( 83 | scope: punctuation.definition.tag.end.xml 84 | pop: true 85 | - include: tag-stuff 86 | - match: '(<\?)(xml-stylesheet|xml-model)(?=\s|\?>)' 87 | captures: 88 | 1: punctuation.definition.tag.begin.xml 89 | 2: entity.name.tag.xml 90 | push: 91 | - meta_scope: meta.tag.preprocessor.xml 92 | - match: \?> 93 | scope: punctuation.definition.tag.end.xml 94 | pop: true 95 | - include: tag-stuff 96 | - match: '(<\?)((?![xX][mM][lL]){{qualified_name}})(?=\s|\?>)' 97 | captures: 98 | 1: punctuation.definition.tag.begin.xml 99 | 2: entity.name.tag.xml 100 | push: 101 | - meta_scope: meta.tag.preprocessor.xml 102 | - match: \?> 103 | scope: punctuation.definition.tag.end.xml 104 | pop: true 105 | - include: entity 106 | - match: '' 111 | scope: punctuation.definition.string.end.xml 112 | pop: true 113 | - match: ']]>' 114 | scope: invalid.illegal.missing-entity.xml 115 | - include: should-be-entity 116 | should-be-entity: 117 | - match: '&' 118 | scope: invalid.illegal.bad-ampersand.xml 119 | - match: '<' 120 | scope: invalid.illegal.missing-entity.xml 121 | double-quoted-string: 122 | - match: '"' 123 | scope: punctuation.definition.string.begin.xml 124 | push: 125 | - meta_scope: string.quoted.double.xml 126 | - match: '"' 127 | scope: punctuation.definition.string.end.xml 128 | pop: true 129 | - include: entity 130 | - include: should-be-entity 131 | entity: 132 | - match: '(&)(?:{{name}}|#[0-9]+|#x\h+)(;)' 133 | scope: constant.character.entity.xml 134 | captures: 135 | 1: punctuation.definition.constant.xml 136 | 2: punctuation.definition.constant.xml 137 | comment: 138 | - match: '' 143 | scope: punctuation.definition.comment.end.xml 144 | pop: true 145 | - match: '-{2,}' 146 | scope: invalid.illegal.double-hyphen-within-comment.xml 147 | internal-subset: 148 | - match: \[ 149 | scope: punctuation.definition.constant.xml 150 | push: 151 | - meta_scope: meta.internalsubset.xml 152 | - match: \] 153 | pop: true 154 | - include: comment 155 | - include: entity-decl 156 | - include: element-decl 157 | - include: attlist-decl 158 | - include: notation-decl 159 | - include: parameter-entity 160 | entity-decl: 161 | - match: '(' 170 | scope: punctuation.definition.tag.end.xml 171 | pop: true 172 | - include: double-quoted-string 173 | - include: single-quoted-string 174 | element-decl: 175 | - match: '(' 182 | scope: punctuation.definition.tag.end.xml 183 | pop: true 184 | - match: '\b(EMPTY|ANY)\b' 185 | scope: constant.other.xml 186 | - include: element-parens 187 | element-parens: 188 | - match: \( 189 | scope: punctuation.definition.group.xml 190 | push: 191 | - match: (\))([*?+])? 192 | captures: 193 | 1: punctuation.definition.group.xml 194 | 2: keyword.operator.xml 195 | pop: true 196 | - match: '#PCDATA' 197 | scope: constant.other.xml 198 | - match: '[*?+]' 199 | scope: keyword.operator.xml 200 | - match: '[,|]' 201 | scope: punctuation.separator.xml 202 | - include: element-parens 203 | attlist-decl: 204 | - match: '(' 212 | scope: punctuation.definition.tag.end.xml 213 | pop: true 214 | - include: double-quoted-string 215 | - include: single-quoted-string 216 | notation-decl: 217 | - match: '(' 224 | scope: punctuation.definition.tag.end.xml 225 | pop: true 226 | - include: double-quoted-string 227 | - include: single-quoted-string 228 | parameter-entity: 229 | - match: '(%){{name}}(;)' 230 | scope: constant.character.parameter-entity.xml 231 | captures: 232 | 1: punctuation.definition.constant.xml 233 | 2: punctuation.definition.constant.xml 234 | single-quoted-string: 235 | - match: "'" 236 | scope: punctuation.definition.string.begin.xml 237 | push: 238 | - meta_scope: string.quoted.single.xml 239 | - match: "'" 240 | scope: punctuation.definition.string.end.xml 241 | pop: true 242 | - include: entity 243 | - include: should-be-entity 244 | tag-stuff: 245 | - match: '(?:\s+|^){{qualified_name}}\s*(=)' 246 | captures: 247 | 1: entity.other.attribute-name.namespace.xml 248 | 2: entity.other.attribute-name.xml punctuation.separator.namespace.xml 249 | 3: entity.other.attribute-name.localname.xml 250 | 4: punctuation.separator.key-value.xml 251 | - match: '(?:\s+|^)([[:alnum:]:_.-]+)\s*(=)' 252 | captures: 253 | 1: invalid.illegal.bad-attribute-name.xml 254 | 2: punctuation.separator.key-value.xml 255 | - include: double-quoted-string 256 | - include: single-quoted-string -------------------------------------------------------------------------------- /assets/themes/ansi.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | name 11 | ANSI Dark 12 | colorSpaceName 13 | sRGB 14 | settings 15 | 16 | 17 | settings 18 | 19 | foreground 20 | #07000000 21 | 22 | 23 | 24 | name 25 | Integers 26 | scope 27 | constant.numeric 28 | settings 29 | 30 | foreground 31 | #04000000 32 | 33 | 34 | 35 | name 36 | Constants 37 | scope 38 | constant 39 | settings 40 | 41 | foreground 42 | #04000000 43 | 44 | 45 | 46 | name 47 | Strings 48 | scope 49 | string.quoted, punctuation.definition.string.begin, punctuation.definition.string.end 50 | settings 51 | 52 | foreground 53 | #03000000 54 | 55 | 56 | 57 | name 58 | Doctype 59 | scope 60 | meta.tag.sgml, entity.name.tag.doctype 61 | settings 62 | 63 | foreground 64 | #06000000 65 | 66 | 67 | 68 | name 69 | Tags 70 | scope 71 | entity.name.tag 72 | settings 73 | 74 | foreground 75 | #0C000000 76 | 77 | 78 | 79 | name 80 | Attributes 81 | scope 82 | entity.other.attribute-name 83 | settings 84 | 85 | foreground 86 | #06000000 87 | 88 | 89 | 90 | name 91 | Header keys 92 | scope 93 | source.http http.requestheaders support.variable.http 94 | settings 95 | 96 | foreground 97 | #06000000 98 | 99 | 100 | 101 | name 102 | Header values 103 | scope 104 | source.http http.requestheaders string.other.http 105 | settings 106 | 107 | foreground 108 | #07000000 109 | 110 | 111 | 112 | name 113 | HTTP version 114 | scope 115 | constant.numeric.http, keyword.other.http 116 | settings 117 | 118 | foreground 119 | #04000000 120 | 121 | 122 | 123 | name 124 | HTTP reason phrase 125 | scope 126 | keyword.reason.http 127 | settings 128 | 129 | foreground 130 | #06000000 131 | 132 | 133 | 134 | name 135 | HTTP method 136 | scope 137 | keyword.control.http 138 | settings 139 | 140 | foreground 141 | #02000000 142 | 143 | 144 | 145 | name 146 | HTTP URL 147 | scope 148 | const.language.http 149 | settings 150 | 151 | fontStyle 152 | underline 153 | foreground 154 | #06000000 155 | 156 | 157 | 158 | name 159 | JSON keys 160 | scope 161 | keyword.other.name.jsonkv 162 | settings 163 | 164 | foreground 165 | #0C000000 166 | 167 | 168 | 169 | name 170 | Error 171 | scope 172 | error 173 | settings 174 | 175 | foreground 176 | #01000000 177 | 178 | 179 | 180 | 181 | 182 | -------------------------------------------------------------------------------- /assets/themes/fruity.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | name 11 | Fruity 12 | colorSpaceName 13 | sRGB 14 | settings 15 | 16 | 17 | settings 18 | 19 | foreground 20 | #0F000000 21 | 22 | 23 | 24 | name 25 | Integers 26 | scope 27 | constant.numeric 28 | settings 29 | 30 | foreground 31 | #21000000 32 | 33 | 34 | 35 | name 36 | Constants 37 | scope 38 | constant 39 | settings 40 | 41 | foreground 42 | #CA000000 43 | 44 | 45 | 46 | name 47 | Strings 48 | scope 49 | string.quoted, punctuation.definition.string.begin, punctuation.definition.string.end 50 | settings 51 | 52 | foreground 53 | #20000000 54 | 55 | 56 | 57 | name 58 | Comments 59 | scope 60 | comment 61 | settings 62 | 63 | foreground 64 | #1C000000 65 | 66 | 67 | 68 | name 69 | Doctype 70 | scope 71 | meta.tag.sgml, entity.name.tag.doctype 72 | settings 73 | 74 | foreground 75 | #40000000 76 | 77 | 78 | 79 | name 80 | Tags 81 | scope 82 | entity.name.tag 83 | settings 84 | 85 | foreground 86 | #CA000000 87 | 88 | 89 | 90 | name 91 | Attributes 92 | scope 93 | entity.other.attribute-name 94 | settings 95 | 96 | foreground 97 | #C6000000 98 | 99 | 100 | 101 | name 102 | Header keys 103 | scope 104 | source.http http.requestheaders support.variable.http 105 | settings 106 | 107 | foreground 108 | #C6000000 109 | 110 | 111 | 112 | name 113 | Header values 114 | scope 115 | source.http http.requestheaders string.other.http 116 | settings 117 | 118 | foreground 119 | #20000000 120 | 121 | 122 | 123 | name 124 | HTTP 125 | scope 126 | keyword.other.http 127 | settings 128 | 129 | foreground 130 | #CA000000 131 | 132 | 133 | 134 | name 135 | HTTP version 136 | scope 137 | constant.numeric.http 138 | settings 139 | 140 | foreground 141 | #21000000 142 | 143 | 144 | 145 | name 146 | HTTP method 147 | scope 148 | keyword.control.http 149 | settings 150 | 151 | foreground 152 | #C6000000 153 | 154 | 155 | 156 | name 157 | JSON keys 158 | scope 159 | keyword.other.name.jsonkv 160 | settings 161 | 162 | foreground 163 | #CA000000 164 | 165 | 166 | 167 | 168 | name 169 | Error 170 | scope 171 | error 172 | settings 173 | 174 | foreground 175 | #01000000 176 | 177 | 178 | 179 | 180 | 181 | -------------------------------------------------------------------------------- /assets/themes/monokai.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | name 11 | ANSI Dark 12 | colorSpaceName 13 | sRGB 14 | settings 15 | 16 | 17 | settings 18 | 19 | foreground 20 | #0F000000 21 | 22 | 23 | 24 | name 25 | Integers 26 | scope 27 | constant.numeric 28 | settings 29 | 30 | foreground 31 | #8D000000 32 | 33 | 34 | 35 | name 36 | Constants 37 | scope 38 | constant 39 | settings 40 | 41 | foreground 42 | #51000000 43 | 44 | 45 | 46 | name 47 | Strings 48 | scope 49 | string.quoted, punctuation.definition.string.begin, punctuation.definition.string.end 50 | settings 51 | 52 | foreground 53 | #BA000000 54 | 55 | 56 | 57 | name 58 | Comments 59 | scope 60 | comment 61 | settings 62 | 63 | foreground 64 | #F2000000 65 | 66 | 67 | 68 | name 69 | Doctype 70 | scope 71 | meta.tag.sgml, entity.name.tag.doctype 72 | settings 73 | 74 | foreground 75 | #F2000000 76 | 77 | 78 | 79 | name 80 | Tags 81 | scope 82 | entity.name.tag 83 | settings 84 | 85 | foreground 86 | #C5000000 87 | 88 | 89 | 90 | name 91 | Attributes 92 | scope 93 | entity.other.attribute-name 94 | settings 95 | 96 | foreground 97 | #94000000 98 | 99 | 100 | 101 | name 102 | Header keys 103 | scope 104 | source.http http.requestheaders support.variable.http 105 | settings 106 | 107 | foreground 108 | #94000000 109 | 110 | 111 | 112 | name 113 | Header values 114 | scope 115 | source.http http.requestheaders string.other.http 116 | settings 117 | 118 | foreground 119 | #BA000000 120 | 121 | 122 | 123 | name 124 | HTTP 125 | scope 126 | keyword.other.http 127 | settings 128 | 129 | foreground 130 | #51000000 131 | 132 | 133 | 134 | name 135 | HTTP separator 136 | scope 137 | punctuation.separator.http 138 | settings 139 | 140 | foreground 141 | #C5000000 142 | 143 | 144 | 145 | name 146 | HTTP version 147 | scope 148 | constant.numeric.http 149 | settings 150 | 151 | foreground 152 | #8D000000 153 | 154 | 155 | 156 | name 157 | HTTP reason phrase 158 | scope 159 | keyword.reason.http 160 | settings 161 | 162 | foreground 163 | #94000000 164 | 165 | 166 | 167 | name 168 | HTTP method 169 | scope 170 | keyword.control.http 171 | settings 172 | 173 | foreground 174 | #94000000 175 | 176 | 177 | 178 | name 179 | JSON keys 180 | scope 181 | keyword.other.name.jsonkv 182 | settings 183 | 184 | foreground 185 | #C5000000 186 | 187 | 188 | 189 | 190 | name 191 | Error 192 | scope 193 | error 194 | settings 195 | 196 | foreground 197 | #01000000 198 | 199 | 200 | 201 | 202 | 203 | -------------------------------------------------------------------------------- /assets/themes/solarized.tmTheme: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 10 | name 11 | Solarized 12 | colorSpaceName 13 | sRGB 14 | settings 15 | 16 | 17 | settings 18 | 19 | foreground 20 | #F5000000 21 | 22 | 23 | 24 | name 25 | Integers 26 | scope 27 | constant.numeric 28 | settings 29 | 30 | foreground 31 | #25000000 32 | 33 | 34 | 35 | name 36 | Constants 37 | scope 38 | constant 39 | settings 40 | 41 | foreground 42 | #A6000000 43 | 44 | 45 | 46 | name 47 | Strings 48 | scope 49 | string.quoted, punctuation.definition.string.begin, punctuation.definition.string.end 50 | settings 51 | 52 | foreground 53 | #25000000 54 | 55 | 56 | 57 | name 58 | Comments 59 | scope 60 | comment 61 | settings 62 | 63 | foreground 64 | #EF000000 65 | 66 | 67 | 68 | name 69 | Doctype 70 | scope 71 | meta.tag.sgml, entity.name.tag.doctype 72 | settings 73 | 74 | foreground 75 | #40000000 76 | 77 | 78 | 79 | name 80 | Tags 81 | scope 82 | entity.name.tag 83 | settings 84 | 85 | foreground 86 | #21000000 87 | 88 | 89 | 90 | name 91 | Attributes 92 | scope 93 | entity.other.attribute-name 94 | settings 95 | 96 | foreground 97 | #F5000000 98 | 99 | 100 | 101 | name 102 | Header keys 103 | scope 104 | source.http http.requestheaders support.variable.http 105 | settings 106 | 107 | foreground 108 | #F5000000 109 | 110 | 111 | 112 | name 113 | Header values 114 | scope 115 | source.http http.requestheaders string.other.http 116 | settings 117 | 118 | foreground 119 | #25000000 120 | 121 | 122 | 123 | name 124 | HTTP 125 | scope 126 | keyword.other.http 127 | settings 128 | 129 | foreground 130 | #21000000 131 | 132 | 133 | 134 | name 135 | HTTP reason phrase 136 | scope 137 | keyword.reason.http 138 | settings 139 | 140 | foreground 141 | #88000000 142 | 143 | 144 | 145 | name 146 | HTTP method 147 | scope 148 | keyword.control.http 149 | settings 150 | 151 | foreground 152 | #21000000 153 | 154 | 155 | 156 | name 157 | HTTP URL 158 | scope 159 | const.language.http 160 | settings 161 | 162 | foreground 163 | #F5000000 164 | 165 | 166 | 167 | name 168 | JSON keys 169 | scope 170 | keyword.other.name.jsonkv 171 | settings 172 | 173 | foreground 174 | #21000000 175 | 176 | 177 | 178 | 179 | name 180 | Error 181 | scope 182 | error 183 | settings 184 | 185 | foreground 186 | #01000000 187 | 188 | 189 | 190 | 191 | 192 | -------------------------------------------------------------------------------- /assets/xh-demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducaale/xh/2ac5bf963b19988c1fe510f2bc097b616127e85a/assets/xh-demo.gif -------------------------------------------------------------------------------- /assets/xhs: -------------------------------------------------------------------------------- 1 | xh -------------------------------------------------------------------------------- /assets/xhs.1.gz: -------------------------------------------------------------------------------- 1 | xh.1.gz -------------------------------------------------------------------------------- /build.rs: -------------------------------------------------------------------------------- 1 | use std::env; 2 | use std::fs::read_dir; 3 | use std::path::Path; 4 | 5 | use syntect::dumps::dump_to_file; 6 | use syntect::highlighting::ThemeSet; 7 | use syntect::parsing::SyntaxSetBuilder; 8 | 9 | fn build_syntax(dir: &str, out: &str) { 10 | let out_dir = env::var_os("OUT_DIR").unwrap(); 11 | let mut builder = SyntaxSetBuilder::new(); 12 | builder.add_from_folder(dir, true).unwrap(); 13 | let ss = builder.build(); 14 | dump_to_file(&ss, Path::new(&out_dir).join(out)).unwrap(); 15 | } 16 | 17 | fn feature_status(feature: &str) -> String { 18 | if env::var_os(format!( 19 | "CARGO_FEATURE_{}", 20 | feature.to_uppercase().replace('-', "_") 21 | )) 22 | .is_some() 23 | { 24 | format!("+{}", feature) 25 | } else { 26 | format!("-{}", feature) 27 | } 28 | } 29 | 30 | fn features() -> String { 31 | format!( 32 | "{} {}", 33 | &feature_status("native-tls"), 34 | &feature_status("rustls") 35 | ) 36 | } 37 | 38 | fn main() { 39 | for dir in [ 40 | "assets/syntax", 41 | "assets/syntax/basic", 42 | "assets/syntax/large", 43 | "assets/themes", 44 | ] { 45 | println!("cargo:rerun-if-changed={}", dir); 46 | for entry in read_dir(dir).unwrap() { 47 | let path = entry.unwrap().path(); 48 | let path = path.to_str().unwrap(); 49 | if path.ends_with(".sublime-syntax") || path.ends_with(".tmTheme") { 50 | println!("cargo:rerun-if-changed={}", path); 51 | } 52 | } 53 | } 54 | 55 | build_syntax("assets/syntax/basic", "basic.packdump"); 56 | build_syntax("assets/syntax/large", "large.packdump"); 57 | 58 | let out_dir = env::var_os("OUT_DIR").unwrap(); 59 | let ts = ThemeSet::load_from_folder("assets/themes").unwrap(); 60 | dump_to_file(&ts, Path::new(&out_dir).join("themepack.themedump")).unwrap(); 61 | 62 | println!("cargo:rustc-env=XH_FEATURES={}", features()); 63 | } 64 | -------------------------------------------------------------------------------- /completions/_xh: -------------------------------------------------------------------------------- 1 | #compdef xh 2 | 3 | autoload -U is-at-least 4 | 5 | _xh() { 6 | typeset -A opt_args 7 | typeset -a _arguments_options 8 | local ret=1 9 | 10 | if is-at-least 5.2; then 11 | _arguments_options=(-s -S -C) 12 | else 13 | _arguments_options=(-s -C) 14 | fi 15 | 16 | local context curcontext="$curcontext" state line 17 | _arguments "${_arguments_options[@]}" : \ 18 | '--raw=[Pass raw request data without extra processing]:RAW:_default' \ 19 | '--pretty=[Controls output processing]:STYLE:((all\:"(default) Enable both coloring and formatting" 20 | colors\:"Apply syntax highlighting to output" 21 | format\:"Pretty-print json and sort headers" 22 | none\:"Disable both coloring and formatting"))' \ 23 | '*--format-options=[Set output formatting options]:FORMAT_OPTIONS:_default' \ 24 | '-s+[Output coloring style]:THEME:(auto solarized monokai fruity)' \ 25 | '--style=[Output coloring style]:THEME:(auto solarized monokai fruity)' \ 26 | '--response-charset=[Override the response encoding for terminal display purposes]:ENCODING:_default' \ 27 | '--response-mime=[Override the response mime type for coloring and formatting for the terminal]:MIME_TYPE:_default' \ 28 | '-p+[String specifying what the output should contain]:FORMAT:_default' \ 29 | '--print=[String specifying what the output should contain]:FORMAT:_default' \ 30 | '-P+[The same as --print but applies only to intermediary requests/responses]:FORMAT:_default' \ 31 | '--history-print=[The same as --print but applies only to intermediary requests/responses]:FORMAT:_default' \ 32 | '-o+[Save output to FILE instead of stdout]:FILE:_files' \ 33 | '--output=[Save output to FILE instead of stdout]:FILE:_files' \ 34 | '--session=[Create, or reuse and update a session]:FILE:_default' \ 35 | '(--session)--session-read-only=[Create or read a session without updating it form the request/response exchange]:FILE:_default' \ 36 | '-A+[Specify the auth mechanism]:AUTH_TYPE:(basic bearer digest)' \ 37 | '--auth-type=[Specify the auth mechanism]:AUTH_TYPE:(basic bearer digest)' \ 38 | '-a+[Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)]:USER[:PASS] | TOKEN:_default' \ 39 | '--auth=[Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)]:USER[:PASS] | TOKEN:_default' \ 40 | '--bearer=[Authenticate with a bearer token]:TOKEN:_default' \ 41 | '--max-redirects=[Number of redirects to follow. Only respected if --follow is used]:NUM:_default' \ 42 | '--timeout=[Connection timeout of the request]:SEC:_default' \ 43 | '*--proxy=[Use a proxy for a protocol. For example\: --proxy https\:http\://proxy.host\:8080]:PROTOCOL:URL:_default' \ 44 | '--verify=[If "no", skip SSL verification. If a file path, use it as a CA bundle]:VERIFY:_default' \ 45 | '--cert=[Use a client side certificate for SSL]:FILE:_files' \ 46 | '--cert-key=[A private key file to use with --cert]:FILE:_files' \ 47 | '--ssl=[Force a particular TLS version]:VERSION:(auto tls1 tls1.1 tls1.2 tls1.3)' \ 48 | '--default-scheme=[The default scheme to use if not specified in the URL]:SCHEME:_default' \ 49 | '--http-version=[HTTP version to use]:VERSION:(1.0 1.1 2 2-prior-knowledge)' \ 50 | '*--resolve=[Override DNS resolution for specific domain to a custom IP]:HOST:ADDRESS:_default' \ 51 | '--interface=[Bind to a network interface or local IP address]:NAME:_default' \ 52 | '()--generate=[Generate shell completions or man pages]:KIND:(complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man)' \ 53 | '-j[(default) Serialize data items from the command line as a JSON object]' \ 54 | '--json[(default) Serialize data items from the command line as a JSON object]' \ 55 | '-f[Serialize data items from the command line as form fields]' \ 56 | '--form[Serialize data items from the command line as form fields]' \ 57 | '(--raw -x --compress)--multipart[Like --form, but force a multipart/form-data request even without files]' \ 58 | '-h[Print only the response headers. Shortcut for --print=h]' \ 59 | '--headers[Print only the response headers. Shortcut for --print=h]' \ 60 | '-b[Print only the response body. Shortcut for --print=b]' \ 61 | '--body[Print only the response body. Shortcut for --print=b]' \ 62 | '-m[Print only the response metadata. Shortcut for --print=m]' \ 63 | '--meta[Print only the response metadata. Shortcut for --print=m]' \ 64 | '*-v[Print the whole request as well as the response]' \ 65 | '*--verbose[Print the whole request as well as the response]' \ 66 | '--debug[Print full error stack traces and debug log messages]' \ 67 | '--all[Show any intermediary requests/responses while following redirects with --follow]' \ 68 | '*-q[Do not print to stdout or stderr]' \ 69 | '*--quiet[Do not print to stdout or stderr]' \ 70 | '-S[Always stream the response body]' \ 71 | '--stream[Always stream the response body]' \ 72 | '*-x[Content compressed (encoded) with Deflate algorithm]' \ 73 | '*--compress[Content compressed (encoded) with Deflate algorithm]' \ 74 | '-d[Download the body to a file instead of printing it]' \ 75 | '--download[Download the body to a file instead of printing it]' \ 76 | '-c[Resume an interrupted download. Requires --download and --output]' \ 77 | '--continue[Resume an interrupted download. Requires --download and --output]' \ 78 | '--ignore-netrc[Do not use credentials from .netrc]' \ 79 | '--offline[Construct HTTP requests without sending them anywhere]' \ 80 | '--check-status[(default) Exit with an error status code if the server replies with an error]' \ 81 | '-F[Do follow redirects]' \ 82 | '--follow[Do follow redirects]' \ 83 | '--native-tls[Use the system TLS library instead of rustls (if enabled at compile time)]' \ 84 | '--https[Make HTTPS requests if not specified in the URL]' \ 85 | '-4[Resolve hostname to ipv4 addresses only]' \ 86 | '--ipv4[Resolve hostname to ipv4 addresses only]' \ 87 | '-6[Resolve hostname to ipv6 addresses only]' \ 88 | '--ipv6[Resolve hostname to ipv6 addresses only]' \ 89 | '-I[Do not attempt to read stdin]' \ 90 | '--ignore-stdin[Do not attempt to read stdin]' \ 91 | '--curl[Print a translation to a curl command]' \ 92 | '--curl-long[Use the long versions of curl'\''s flags]' \ 93 | '--help[Print help]' \ 94 | '--no-json[]' \ 95 | '--no-form[]' \ 96 | '--no-multipart[]' \ 97 | '--no-raw[]' \ 98 | '--no-pretty[]' \ 99 | '--no-format-options[]' \ 100 | '--no-style[]' \ 101 | '--no-response-charset[]' \ 102 | '--no-response-mime[]' \ 103 | '--no-print[]' \ 104 | '--no-headers[]' \ 105 | '--no-body[]' \ 106 | '--no-meta[]' \ 107 | '--no-verbose[]' \ 108 | '--no-debug[]' \ 109 | '--no-all[]' \ 110 | '--no-history-print[]' \ 111 | '--no-quiet[]' \ 112 | '--no-stream[]' \ 113 | '--no-compress[]' \ 114 | '--no-output[]' \ 115 | '--no-download[]' \ 116 | '--no-continue[]' \ 117 | '--no-session[]' \ 118 | '--no-session-read-only[]' \ 119 | '--no-auth-type[]' \ 120 | '--no-auth[]' \ 121 | '--no-bearer[]' \ 122 | '--no-ignore-netrc[]' \ 123 | '--no-offline[]' \ 124 | '--no-check-status[]' \ 125 | '--no-follow[]' \ 126 | '--no-max-redirects[]' \ 127 | '--no-timeout[]' \ 128 | '--no-proxy[]' \ 129 | '--no-verify[]' \ 130 | '--no-cert[]' \ 131 | '--no-cert-key[]' \ 132 | '--no-ssl[]' \ 133 | '--no-native-tls[]' \ 134 | '--no-default-scheme[]' \ 135 | '--no-https[]' \ 136 | '--no-http-version[]' \ 137 | '--no-resolve[]' \ 138 | '--no-interface[]' \ 139 | '--no-ipv4[]' \ 140 | '--no-ipv6[]' \ 141 | '--no-ignore-stdin[]' \ 142 | '--no-curl[]' \ 143 | '--no-curl-long[]' \ 144 | '--no-generate[]' \ 145 | '--no-help[]' \ 146 | '-V[Print version]' \ 147 | '--version[Print version]' \ 148 | ':raw_method_or_url -- The request URL, preceded by an optional HTTP method:_default' \ 149 | '*::raw_rest_args -- Optional key-value pairs to be included in the request.:_default' \ 150 | && ret=0 151 | } 152 | 153 | (( $+functions[_xh_commands] )) || 154 | _xh_commands() { 155 | local commands; commands=() 156 | _describe -t commands 'xh commands' commands "$@" 157 | } 158 | 159 | if [ "$funcstack[1]" = "_xh" ]; then 160 | _xh "$@" 161 | else 162 | compdef _xh xh 163 | fi 164 | -------------------------------------------------------------------------------- /completions/xh.bash: -------------------------------------------------------------------------------- 1 | _xh() { 2 | local i cur prev opts cmd 3 | COMPREPLY=() 4 | cur="${COMP_WORDS[COMP_CWORD]}" 5 | prev="${COMP_WORDS[COMP_CWORD-1]}" 6 | cmd="" 7 | opts="" 8 | 9 | for i in ${COMP_WORDS[@]} 10 | do 11 | case "${cmd},${i}" in 12 | ",$1") 13 | cmd="xh" 14 | ;; 15 | *) 16 | ;; 17 | esac 18 | done 19 | 20 | case "${cmd}" in 21 | xh) 22 | opts="-j -f -s -p -h -b -m -v -P -q -S -x -o -d -c -A -a -F -4 -6 -I -V --json --form --multipart --raw --pretty --format-options --style --response-charset --response-mime --print --headers --body --meta --verbose --debug --all --history-print --quiet --stream --compress --output --download --continue --session --session-read-only --auth-type --auth --bearer --ignore-netrc --offline --check-status --follow --max-redirects --timeout --proxy --verify --cert --cert-key --ssl --native-tls --default-scheme --https --http-version --resolve --interface --ipv4 --ipv6 --ignore-stdin --curl --curl-long --generate --help --no-json --no-form --no-multipart --no-raw --no-pretty --no-format-options --no-style --no-response-charset --no-response-mime --no-print --no-headers --no-body --no-meta --no-verbose --no-debug --no-all --no-history-print --no-quiet --no-stream --no-compress --no-output --no-download --no-continue --no-session --no-session-read-only --no-auth-type --no-auth --no-bearer --no-ignore-netrc --no-offline --no-check-status --no-follow --no-max-redirects --no-timeout --no-proxy --no-verify --no-cert --no-cert-key --no-ssl --no-native-tls --no-default-scheme --no-https --no-http-version --no-resolve --no-interface --no-ipv4 --no-ipv6 --no-ignore-stdin --no-curl --no-curl-long --no-generate --no-help --version <[METHOD] URL> [REQUEST_ITEM]..." 23 | if [[ ${cur} == -* || ${COMP_CWORD} -eq 1 ]] ; then 24 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 25 | return 0 26 | fi 27 | case "${prev}" in 28 | --raw) 29 | COMPREPLY=($(compgen -f "${cur}")) 30 | return 0 31 | ;; 32 | --pretty) 33 | COMPREPLY=($(compgen -W "all colors format none" -- "${cur}")) 34 | return 0 35 | ;; 36 | --format-options) 37 | COMPREPLY=($(compgen -f "${cur}")) 38 | return 0 39 | ;; 40 | --style) 41 | COMPREPLY=($(compgen -W "auto solarized monokai fruity" -- "${cur}")) 42 | return 0 43 | ;; 44 | -s) 45 | COMPREPLY=($(compgen -W "auto solarized monokai fruity" -- "${cur}")) 46 | return 0 47 | ;; 48 | --response-charset) 49 | COMPREPLY=($(compgen -f "${cur}")) 50 | return 0 51 | ;; 52 | --response-mime) 53 | COMPREPLY=($(compgen -f "${cur}")) 54 | return 0 55 | ;; 56 | --print) 57 | COMPREPLY=($(compgen -f "${cur}")) 58 | return 0 59 | ;; 60 | -p) 61 | COMPREPLY=($(compgen -f "${cur}")) 62 | return 0 63 | ;; 64 | --history-print) 65 | COMPREPLY=($(compgen -f "${cur}")) 66 | return 0 67 | ;; 68 | -P) 69 | COMPREPLY=($(compgen -f "${cur}")) 70 | return 0 71 | ;; 72 | --output) 73 | COMPREPLY=($(compgen -f "${cur}")) 74 | return 0 75 | ;; 76 | -o) 77 | COMPREPLY=($(compgen -f "${cur}")) 78 | return 0 79 | ;; 80 | --session) 81 | COMPREPLY=($(compgen -f "${cur}")) 82 | return 0 83 | ;; 84 | --session-read-only) 85 | COMPREPLY=($(compgen -f "${cur}")) 86 | return 0 87 | ;; 88 | --auth-type) 89 | COMPREPLY=($(compgen -W "basic bearer digest" -- "${cur}")) 90 | return 0 91 | ;; 92 | -A) 93 | COMPREPLY=($(compgen -W "basic bearer digest" -- "${cur}")) 94 | return 0 95 | ;; 96 | --auth) 97 | COMPREPLY=($(compgen -f "${cur}")) 98 | return 0 99 | ;; 100 | -a) 101 | COMPREPLY=($(compgen -f "${cur}")) 102 | return 0 103 | ;; 104 | --bearer) 105 | COMPREPLY=($(compgen -f "${cur}")) 106 | return 0 107 | ;; 108 | --max-redirects) 109 | COMPREPLY=($(compgen -f "${cur}")) 110 | return 0 111 | ;; 112 | --timeout) 113 | COMPREPLY=($(compgen -f "${cur}")) 114 | return 0 115 | ;; 116 | --proxy) 117 | COMPREPLY=($(compgen -f "${cur}")) 118 | return 0 119 | ;; 120 | --verify) 121 | COMPREPLY=($(compgen -f "${cur}")) 122 | return 0 123 | ;; 124 | --cert) 125 | COMPREPLY=($(compgen -f "${cur}")) 126 | return 0 127 | ;; 128 | --cert-key) 129 | COMPREPLY=($(compgen -f "${cur}")) 130 | return 0 131 | ;; 132 | --ssl) 133 | COMPREPLY=($(compgen -W "auto tls1 tls1.1 tls1.2 tls1.3" -- "${cur}")) 134 | return 0 135 | ;; 136 | --default-scheme) 137 | COMPREPLY=($(compgen -f "${cur}")) 138 | return 0 139 | ;; 140 | --http-version) 141 | COMPREPLY=($(compgen -W "1.0 1.1 2 2-prior-knowledge" -- "${cur}")) 142 | return 0 143 | ;; 144 | --resolve) 145 | COMPREPLY=($(compgen -f "${cur}")) 146 | return 0 147 | ;; 148 | --interface) 149 | COMPREPLY=($(compgen -f "${cur}")) 150 | return 0 151 | ;; 152 | --generate) 153 | COMPREPLY=($(compgen -W "complete-bash complete-elvish complete-fish complete-nushell complete-powershell complete-zsh man" -- "${cur}")) 154 | return 0 155 | ;; 156 | *) 157 | COMPREPLY=() 158 | ;; 159 | esac 160 | COMPREPLY=( $(compgen -W "${opts}" -- "${cur}") ) 161 | return 0 162 | ;; 163 | esac 164 | } 165 | 166 | if [[ "${BASH_VERSINFO[0]}" -eq 4 && "${BASH_VERSINFO[1]}" -ge 4 || "${BASH_VERSINFO[0]}" -gt 4 ]]; then 167 | complete -F _xh -o nosort -o bashdefault -o default xh 168 | else 169 | complete -F _xh -o bashdefault -o default xh 170 | fi 171 | -------------------------------------------------------------------------------- /completions/xh.elv: -------------------------------------------------------------------------------- 1 | 2 | use builtin; 3 | use str; 4 | 5 | set edit:completion:arg-completer[xh] = {|@words| 6 | fn spaces {|n| 7 | builtin:repeat $n ' ' | str:join '' 8 | } 9 | fn cand {|text desc| 10 | edit:complex-candidate $text &display=$text' '(spaces (- 14 (wcswidth $text)))$desc 11 | } 12 | var command = 'xh' 13 | for word $words[1..-1] { 14 | if (str:has-prefix $word '-') { 15 | break 16 | } 17 | set command = $command';'$word 18 | } 19 | var completions = [ 20 | &'xh'= { 21 | cand --raw 'Pass raw request data without extra processing' 22 | cand --pretty 'Controls output processing' 23 | cand --format-options 'Set output formatting options' 24 | cand -s 'Output coloring style' 25 | cand --style 'Output coloring style' 26 | cand --response-charset 'Override the response encoding for terminal display purposes' 27 | cand --response-mime 'Override the response mime type for coloring and formatting for the terminal' 28 | cand -p 'String specifying what the output should contain' 29 | cand --print 'String specifying what the output should contain' 30 | cand -P 'The same as --print but applies only to intermediary requests/responses' 31 | cand --history-print 'The same as --print but applies only to intermediary requests/responses' 32 | cand -o 'Save output to FILE instead of stdout' 33 | cand --output 'Save output to FILE instead of stdout' 34 | cand --session 'Create, or reuse and update a session' 35 | cand --session-read-only 'Create or read a session without updating it form the request/response exchange' 36 | cand -A 'Specify the auth mechanism' 37 | cand --auth-type 'Specify the auth mechanism' 38 | cand -a 'Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)' 39 | cand --auth 'Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)' 40 | cand --bearer 'Authenticate with a bearer token' 41 | cand --max-redirects 'Number of redirects to follow. Only respected if --follow is used' 42 | cand --timeout 'Connection timeout of the request' 43 | cand --proxy 'Use a proxy for a protocol. For example: --proxy https:http://proxy.host:8080' 44 | cand --verify 'If "no", skip SSL verification. If a file path, use it as a CA bundle' 45 | cand --cert 'Use a client side certificate for SSL' 46 | cand --cert-key 'A private key file to use with --cert' 47 | cand --ssl 'Force a particular TLS version' 48 | cand --default-scheme 'The default scheme to use if not specified in the URL' 49 | cand --http-version 'HTTP version to use' 50 | cand --resolve 'Override DNS resolution for specific domain to a custom IP' 51 | cand --interface 'Bind to a network interface or local IP address' 52 | cand --generate 'Generate shell completions or man pages' 53 | cand -j '(default) Serialize data items from the command line as a JSON object' 54 | cand --json '(default) Serialize data items from the command line as a JSON object' 55 | cand -f 'Serialize data items from the command line as form fields' 56 | cand --form 'Serialize data items from the command line as form fields' 57 | cand --multipart 'Like --form, but force a multipart/form-data request even without files' 58 | cand -h 'Print only the response headers. Shortcut for --print=h' 59 | cand --headers 'Print only the response headers. Shortcut for --print=h' 60 | cand -b 'Print only the response body. Shortcut for --print=b' 61 | cand --body 'Print only the response body. Shortcut for --print=b' 62 | cand -m 'Print only the response metadata. Shortcut for --print=m' 63 | cand --meta 'Print only the response metadata. Shortcut for --print=m' 64 | cand -v 'Print the whole request as well as the response' 65 | cand --verbose 'Print the whole request as well as the response' 66 | cand --debug 'Print full error stack traces and debug log messages' 67 | cand --all 'Show any intermediary requests/responses while following redirects with --follow' 68 | cand -q 'Do not print to stdout or stderr' 69 | cand --quiet 'Do not print to stdout or stderr' 70 | cand -S 'Always stream the response body' 71 | cand --stream 'Always stream the response body' 72 | cand -x 'Content compressed (encoded) with Deflate algorithm' 73 | cand --compress 'Content compressed (encoded) with Deflate algorithm' 74 | cand -d 'Download the body to a file instead of printing it' 75 | cand --download 'Download the body to a file instead of printing it' 76 | cand -c 'Resume an interrupted download. Requires --download and --output' 77 | cand --continue 'Resume an interrupted download. Requires --download and --output' 78 | cand --ignore-netrc 'Do not use credentials from .netrc' 79 | cand --offline 'Construct HTTP requests without sending them anywhere' 80 | cand --check-status '(default) Exit with an error status code if the server replies with an error' 81 | cand -F 'Do follow redirects' 82 | cand --follow 'Do follow redirects' 83 | cand --native-tls 'Use the system TLS library instead of rustls (if enabled at compile time)' 84 | cand --https 'Make HTTPS requests if not specified in the URL' 85 | cand -4 'Resolve hostname to ipv4 addresses only' 86 | cand --ipv4 'Resolve hostname to ipv4 addresses only' 87 | cand -6 'Resolve hostname to ipv6 addresses only' 88 | cand --ipv6 'Resolve hostname to ipv6 addresses only' 89 | cand -I 'Do not attempt to read stdin' 90 | cand --ignore-stdin 'Do not attempt to read stdin' 91 | cand --curl 'Print a translation to a curl command' 92 | cand --curl-long 'Use the long versions of curl''s flags' 93 | cand --help 'Print help' 94 | cand --no-json 'no-json' 95 | cand --no-form 'no-form' 96 | cand --no-multipart 'no-multipart' 97 | cand --no-raw 'no-raw' 98 | cand --no-pretty 'no-pretty' 99 | cand --no-format-options 'no-format-options' 100 | cand --no-style 'no-style' 101 | cand --no-response-charset 'no-response-charset' 102 | cand --no-response-mime 'no-response-mime' 103 | cand --no-print 'no-print' 104 | cand --no-headers 'no-headers' 105 | cand --no-body 'no-body' 106 | cand --no-meta 'no-meta' 107 | cand --no-verbose 'no-verbose' 108 | cand --no-debug 'no-debug' 109 | cand --no-all 'no-all' 110 | cand --no-history-print 'no-history-print' 111 | cand --no-quiet 'no-quiet' 112 | cand --no-stream 'no-stream' 113 | cand --no-compress 'no-compress' 114 | cand --no-output 'no-output' 115 | cand --no-download 'no-download' 116 | cand --no-continue 'no-continue' 117 | cand --no-session 'no-session' 118 | cand --no-session-read-only 'no-session-read-only' 119 | cand --no-auth-type 'no-auth-type' 120 | cand --no-auth 'no-auth' 121 | cand --no-bearer 'no-bearer' 122 | cand --no-ignore-netrc 'no-ignore-netrc' 123 | cand --no-offline 'no-offline' 124 | cand --no-check-status 'no-check-status' 125 | cand --no-follow 'no-follow' 126 | cand --no-max-redirects 'no-max-redirects' 127 | cand --no-timeout 'no-timeout' 128 | cand --no-proxy 'no-proxy' 129 | cand --no-verify 'no-verify' 130 | cand --no-cert 'no-cert' 131 | cand --no-cert-key 'no-cert-key' 132 | cand --no-ssl 'no-ssl' 133 | cand --no-native-tls 'no-native-tls' 134 | cand --no-default-scheme 'no-default-scheme' 135 | cand --no-https 'no-https' 136 | cand --no-http-version 'no-http-version' 137 | cand --no-resolve 'no-resolve' 138 | cand --no-interface 'no-interface' 139 | cand --no-ipv4 'no-ipv4' 140 | cand --no-ipv6 'no-ipv6' 141 | cand --no-ignore-stdin 'no-ignore-stdin' 142 | cand --no-curl 'no-curl' 143 | cand --no-curl-long 'no-curl-long' 144 | cand --no-generate 'no-generate' 145 | cand --no-help 'no-help' 146 | cand -V 'Print version' 147 | cand --version 'Print version' 148 | } 149 | ] 150 | $completions[$command] 151 | } 152 | -------------------------------------------------------------------------------- /completions/xh.fish: -------------------------------------------------------------------------------- 1 | # Complete paths after @ in options: 2 | function __xh_complete_data 3 | string match -qr '^(?.*@)(?.*)' -- (commandline -ct) 4 | printf '%s\n' -- $prefix(__fish_complete_path $path) 5 | end 6 | complete -c xh -n 'string match -qr "@" -- (commandline -ct)' -kxa "(__xh_complete_data)" 7 | 8 | complete -c xh -l raw -d 'Pass raw request data without extra processing' -r 9 | complete -c xh -l pretty -d 'Controls output processing' -r -f -a "all\t'(default) Enable both coloring and formatting' 10 | colors\t'Apply syntax highlighting to output' 11 | format\t'Pretty-print json and sort headers' 12 | none\t'Disable both coloring and formatting'" 13 | complete -c xh -l format-options -d 'Set output formatting options' -r 14 | complete -c xh -s s -l style -d 'Output coloring style' -r -f -a "auto\t'' 15 | solarized\t'' 16 | monokai\t'' 17 | fruity\t''" 18 | complete -c xh -l response-charset -d 'Override the response encoding for terminal display purposes' -r 19 | complete -c xh -l response-mime -d 'Override the response mime type for coloring and formatting for the terminal' -r 20 | complete -c xh -s p -l print -d 'String specifying what the output should contain' -r 21 | complete -c xh -s P -l history-print -d 'The same as --print but applies only to intermediary requests/responses' -r 22 | complete -c xh -s o -l output -d 'Save output to FILE instead of stdout' -r -F 23 | complete -c xh -l session -d 'Create, or reuse and update a session' -r 24 | complete -c xh -l session-read-only -d 'Create or read a session without updating it form the request/response exchange' -r 25 | complete -c xh -s A -l auth-type -d 'Specify the auth mechanism' -r -f -a "basic\t'' 26 | bearer\t'' 27 | digest\t''" 28 | complete -c xh -s a -l auth -d 'Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer)' -r 29 | complete -c xh -l bearer -d 'Authenticate with a bearer token' -r 30 | complete -c xh -l max-redirects -d 'Number of redirects to follow. Only respected if --follow is used' -r 31 | complete -c xh -l timeout -d 'Connection timeout of the request' -r 32 | complete -c xh -l proxy -d 'Use a proxy for a protocol. For example: --proxy https:http://proxy.host:8080' -r 33 | complete -c xh -l verify -d 'If "no", skip SSL verification. If a file path, use it as a CA bundle' -r 34 | complete -c xh -l cert -d 'Use a client side certificate for SSL' -r -F 35 | complete -c xh -l cert-key -d 'A private key file to use with --cert' -r -F 36 | complete -c xh -l ssl -d 'Force a particular TLS version' -r -f -a "auto\t'' 37 | tls1\t'' 38 | tls1.1\t'' 39 | tls1.2\t'' 40 | tls1.3\t''" 41 | complete -c xh -l default-scheme -d 'The default scheme to use if not specified in the URL' -r 42 | complete -c xh -l http-version -d 'HTTP version to use' -r -f -a "1.0\t'' 43 | 1.1\t'' 44 | 2\t'' 45 | 2-prior-knowledge\t''" 46 | complete -c xh -l resolve -d 'Override DNS resolution for specific domain to a custom IP' -r 47 | complete -c xh -l interface -d 'Bind to a network interface or local IP address' -r 48 | complete -c xh -l generate -d 'Generate shell completions or man pages' -r -f -a "complete-bash\t'' 49 | complete-elvish\t'' 50 | complete-fish\t'' 51 | complete-nushell\t'' 52 | complete-powershell\t'' 53 | complete-zsh\t'' 54 | man\t''" 55 | complete -c xh -s j -l json -d '(default) Serialize data items from the command line as a JSON object' 56 | complete -c xh -s f -l form -d 'Serialize data items from the command line as form fields' 57 | complete -c xh -l multipart -d 'Like --form, but force a multipart/form-data request even without files' 58 | complete -c xh -s h -l headers -d 'Print only the response headers. Shortcut for --print=h' 59 | complete -c xh -s b -l body -d 'Print only the response body. Shortcut for --print=b' 60 | complete -c xh -s m -l meta -d 'Print only the response metadata. Shortcut for --print=m' 61 | complete -c xh -s v -l verbose -d 'Print the whole request as well as the response' 62 | complete -c xh -l debug -d 'Print full error stack traces and debug log messages' 63 | complete -c xh -l all -d 'Show any intermediary requests/responses while following redirects with --follow' 64 | complete -c xh -s q -l quiet -d 'Do not print to stdout or stderr' 65 | complete -c xh -s S -l stream -d 'Always stream the response body' 66 | complete -c xh -s x -l compress -d 'Content compressed (encoded) with Deflate algorithm' 67 | complete -c xh -s d -l download -d 'Download the body to a file instead of printing it' 68 | complete -c xh -s c -l continue -d 'Resume an interrupted download. Requires --download and --output' 69 | complete -c xh -l ignore-netrc -d 'Do not use credentials from .netrc' 70 | complete -c xh -l offline -d 'Construct HTTP requests without sending them anywhere' 71 | complete -c xh -l check-status -d '(default) Exit with an error status code if the server replies with an error' 72 | complete -c xh -s F -l follow -d 'Do follow redirects' 73 | complete -c xh -l native-tls -d 'Use the system TLS library instead of rustls (if enabled at compile time)' 74 | complete -c xh -l https -d 'Make HTTPS requests if not specified in the URL' 75 | complete -c xh -s 4 -l ipv4 -d 'Resolve hostname to ipv4 addresses only' 76 | complete -c xh -s 6 -l ipv6 -d 'Resolve hostname to ipv6 addresses only' 77 | complete -c xh -s I -l ignore-stdin -d 'Do not attempt to read stdin' 78 | complete -c xh -l curl -d 'Print a translation to a curl command' 79 | complete -c xh -l curl-long -d 'Use the long versions of curl\'s flags' 80 | complete -c xh -l help -d 'Print help' 81 | complete -c xh -l no-json 82 | complete -c xh -l no-form 83 | complete -c xh -l no-multipart 84 | complete -c xh -l no-raw 85 | complete -c xh -l no-pretty 86 | complete -c xh -l no-format-options 87 | complete -c xh -l no-style 88 | complete -c xh -l no-response-charset 89 | complete -c xh -l no-response-mime 90 | complete -c xh -l no-print 91 | complete -c xh -l no-headers 92 | complete -c xh -l no-body 93 | complete -c xh -l no-meta 94 | complete -c xh -l no-verbose 95 | complete -c xh -l no-debug 96 | complete -c xh -l no-all 97 | complete -c xh -l no-history-print 98 | complete -c xh -l no-quiet 99 | complete -c xh -l no-stream 100 | complete -c xh -l no-compress 101 | complete -c xh -l no-output 102 | complete -c xh -l no-download 103 | complete -c xh -l no-continue 104 | complete -c xh -l no-session 105 | complete -c xh -l no-session-read-only 106 | complete -c xh -l no-auth-type 107 | complete -c xh -l no-auth 108 | complete -c xh -l no-bearer 109 | complete -c xh -l no-ignore-netrc 110 | complete -c xh -l no-offline 111 | complete -c xh -l no-check-status 112 | complete -c xh -l no-follow 113 | complete -c xh -l no-max-redirects 114 | complete -c xh -l no-timeout 115 | complete -c xh -l no-proxy 116 | complete -c xh -l no-verify 117 | complete -c xh -l no-cert 118 | complete -c xh -l no-cert-key 119 | complete -c xh -l no-ssl 120 | complete -c xh -l no-native-tls 121 | complete -c xh -l no-default-scheme 122 | complete -c xh -l no-https 123 | complete -c xh -l no-http-version 124 | complete -c xh -l no-resolve 125 | complete -c xh -l no-interface 126 | complete -c xh -l no-ipv4 127 | complete -c xh -l no-ipv6 128 | complete -c xh -l no-ignore-stdin 129 | complete -c xh -l no-curl 130 | complete -c xh -l no-curl-long 131 | complete -c xh -l no-generate 132 | complete -c xh -l no-help 133 | complete -c xh -s V -l version -d 'Print version' 134 | -------------------------------------------------------------------------------- /completions/xh.nu: -------------------------------------------------------------------------------- 1 | module completions { 2 | 3 | def "nu-complete xh pretty" [] { 4 | [ "all" "colors" "format" "none" ] 5 | } 6 | 7 | def "nu-complete xh style" [] { 8 | [ "auto" "solarized" "monokai" "fruity" ] 9 | } 10 | 11 | def "nu-complete xh auth_type" [] { 12 | [ "basic" "bearer" "digest" ] 13 | } 14 | 15 | def "nu-complete xh ssl" [] { 16 | [ "auto" "tls1" "tls1.1" "tls1.2" "tls1.3" ] 17 | } 18 | 19 | def "nu-complete xh http_version" [] { 20 | [ "1.0" "1.1" "2" "2-prior-knowledge" ] 21 | } 22 | 23 | def "nu-complete xh generate" [] { 24 | [ "complete-bash" "complete-elvish" "complete-fish" "complete-nushell" "complete-powershell" "complete-zsh" "man" ] 25 | } 26 | 27 | # xh is a friendly and fast tool for sending HTTP requests 28 | export extern xh [ 29 | --json(-j) # (default) Serialize data items from the command line as a JSON object 30 | --form(-f) # Serialize data items from the command line as form fields 31 | --multipart # Like --form, but force a multipart/form-data request even without files 32 | --raw: string # Pass raw request data without extra processing 33 | --pretty: string@"nu-complete xh pretty" # Controls output processing 34 | --format-options: string # Set output formatting options 35 | --style(-s): string@"nu-complete xh style" # Output coloring style 36 | --response-charset: string # Override the response encoding for terminal display purposes 37 | --response-mime: string # Override the response mime type for coloring and formatting for the terminal 38 | --print(-p): string # String specifying what the output should contain 39 | --headers(-h) # Print only the response headers. Shortcut for --print=h 40 | --body(-b) # Print only the response body. Shortcut for --print=b 41 | --meta(-m) # Print only the response metadata. Shortcut for --print=m 42 | --verbose(-v) # Print the whole request as well as the response 43 | --debug # Print full error stack traces and debug log messages 44 | --all # Show any intermediary requests/responses while following redirects with --follow 45 | --history-print(-P): string # The same as --print but applies only to intermediary requests/responses 46 | --quiet(-q) # Do not print to stdout or stderr 47 | --stream(-S) # Always stream the response body 48 | --compress(-x) # Content compressed (encoded) with Deflate algorithm 49 | --output(-o): path # Save output to FILE instead of stdout 50 | --download(-d) # Download the body to a file instead of printing it 51 | --continue(-c) # Resume an interrupted download. Requires --download and --output 52 | --session: string # Create, or reuse and update a session 53 | --session-read-only: string # Create or read a session without updating it form the request/response exchange 54 | --auth-type(-A): string@"nu-complete xh auth_type" # Specify the auth mechanism 55 | --auth(-a): string # Authenticate as USER with PASS (-A basic|digest) or with TOKEN (-A bearer) 56 | --bearer: string # Authenticate with a bearer token 57 | --ignore-netrc # Do not use credentials from .netrc 58 | --offline # Construct HTTP requests without sending them anywhere 59 | --check-status # (default) Exit with an error status code if the server replies with an error 60 | --follow(-F) # Do follow redirects 61 | --max-redirects: string # Number of redirects to follow. Only respected if --follow is used 62 | --timeout: string # Connection timeout of the request 63 | --proxy: string # Use a proxy for a protocol. For example: --proxy https:http://proxy.host:8080 64 | --verify: string # If "no", skip SSL verification. If a file path, use it as a CA bundle 65 | --cert: path # Use a client side certificate for SSL 66 | --cert-key: path # A private key file to use with --cert 67 | --ssl: string@"nu-complete xh ssl" # Force a particular TLS version 68 | --native-tls # Use the system TLS library instead of rustls (if enabled at compile time) 69 | --default-scheme: string # The default scheme to use if not specified in the URL 70 | --https # Make HTTPS requests if not specified in the URL 71 | --http-version: string@"nu-complete xh http_version" # HTTP version to use 72 | --resolve: string # Override DNS resolution for specific domain to a custom IP 73 | --interface: string # Bind to a network interface or local IP address 74 | --ipv4(-4) # Resolve hostname to ipv4 addresses only 75 | --ipv6(-6) # Resolve hostname to ipv6 addresses only 76 | --ignore-stdin(-I) # Do not attempt to read stdin 77 | --curl # Print a translation to a curl command 78 | --curl-long # Use the long versions of curl's flags 79 | --generate: string@"nu-complete xh generate" # Generate shell completions or man pages 80 | --help # Print help 81 | raw_method_or_url: string # The request URL, preceded by an optional HTTP method 82 | ...raw_rest_args: string # Optional key-value pairs to be included in the request. 83 | --no-json 84 | --no-form 85 | --no-multipart 86 | --no-raw 87 | --no-pretty 88 | --no-format-options 89 | --no-style 90 | --no-response-charset 91 | --no-response-mime 92 | --no-print 93 | --no-headers 94 | --no-body 95 | --no-meta 96 | --no-verbose 97 | --no-debug 98 | --no-all 99 | --no-history-print 100 | --no-quiet 101 | --no-stream 102 | --no-compress 103 | --no-output 104 | --no-download 105 | --no-continue 106 | --no-session 107 | --no-session-read-only 108 | --no-auth-type 109 | --no-auth 110 | --no-bearer 111 | --no-ignore-netrc 112 | --no-offline 113 | --no-check-status 114 | --no-follow 115 | --no-max-redirects 116 | --no-timeout 117 | --no-proxy 118 | --no-verify 119 | --no-cert 120 | --no-cert-key 121 | --no-ssl 122 | --no-native-tls 123 | --no-default-scheme 124 | --no-https 125 | --no-http-version 126 | --no-resolve 127 | --no-interface 128 | --no-ipv4 129 | --no-ipv6 130 | --no-ignore-stdin 131 | --no-curl 132 | --no-curl-long 133 | --no-generate 134 | --no-help 135 | --version(-V) # Print version 136 | ] 137 | 138 | } 139 | 140 | export use completions * 141 | -------------------------------------------------------------------------------- /doc/man-template.roff: -------------------------------------------------------------------------------- 1 | .TH XH 1 {{date}} {{version}} "User Commands" 2 | 3 | .SH NAME 4 | xh \- Friendly and fast tool for sending HTTP requests 5 | 6 | .SH SYNOPSIS 7 | .B xh 8 | [\fIOPTIONS\fR] 9 | [\fIMETHOD\fR] 10 | \fIURL\fR 11 | [\-\-\] 12 | [\fIREQUEST_ITEM\fR ...] 13 | 14 | .SH DESCRIPTION 15 | 16 | \fBxh\fR is an HTTP client with a friendly command line interface. It strives to 17 | have readable output and easy-to-use options. 18 | 19 | xh is mostly compatible with HTTPie: see \fBhttp\fR(1). 20 | 21 | The \fB--curl\fR option can be used to print a \fBcurl\fR(1) translation of the 22 | command instead of sending a request. 23 | 24 | .SH POSITIONAL ARGUMENTS 25 | .TP 4 26 | [\fIMETHOD\fR]\fI 27 | The HTTP method to use for the request. 28 | 29 | This defaults to GET, or to POST if the request contains a body. 30 | .TP 31 | \fIURL\fR 32 | The URL to request. 33 | 34 | The URL scheme defaults to "http://" normally, or "https://" if 35 | the program is invoked as "xhs". 36 | 37 | A leading colon works as shorthand for localhost. ":8000" is equivalent 38 | to "localhost:8000", and ":/path" is equivalent to "localhost/path". 39 | .TP 40 | [\fIREQUEST_ITEM\fR ...] 41 | {{request_items}} 42 | 43 | .SH OPTIONS 44 | Each --OPTION can be reset with a --no-OPTION argument. 45 | {{options}} 46 | 47 | .SH EXIT STATUS 48 | .TP 4 49 | .B 0 50 | Successful program execution. 51 | .TP 52 | .B 1 53 | Usage, syntax or network error. 54 | .TP 55 | .B 2 56 | Request timeout. 57 | .TP 58 | .B 3 59 | Unexpected HTTP 3xx Redirection. 60 | .TP 61 | .B 4 62 | HTTP 4xx Client Error. 63 | .TP 64 | .B 5 65 | HTTP 5xx Server Error. 66 | .TP 67 | .B 6 68 | Too many redirects. 69 | 70 | .SH ENVIRONMENT 71 | .TP 4 72 | .B XH_CONFIG_DIR 73 | Specifies where to look for config.json and named session data. 74 | The default is ~/.config/xh for Linux/macOS and %APPDATA%\\xh for Windows. 75 | .TP 76 | .B XH_HTTPIE_COMPAT_MODE 77 | Enables the HTTPie Compatibility Mode. The only current difference is that 78 | \-\-check-status is not enabled by default. An alternative to setting this 79 | environment variable is to rename the binary to either http or https. 80 | .TP 81 | .BR REQUESTS_CA_BUNDLE ", " CURL_CA_BUNDLE 82 | Sets a custom CA bundle path. 83 | .TP 84 | .BR http_proxy "=[protocol://][:port]" 85 | Sets the proxy server to use for HTTP. 86 | .TP 87 | .BR HTTPS_PROXY "=[protocol://][:port]" 88 | Sets the proxy server to use for HTTPS. 89 | .TP 90 | .B NO_PROXY 91 | List of comma-separated hosts for which to ignore the other proxy environment 92 | variables. "*" matches all host names. 93 | .TP 94 | .B NETRC 95 | Location of the .netrc file. 96 | .TP 97 | .B NO_COLOR 98 | Disables output coloring. See 99 | .TP 100 | .B RUST_LOG 101 | Configure low-level debug messages. See 102 | 103 | .SH FILES 104 | .TP 4 105 | .I ~/.config/xh/config.json 106 | xh configuration file. The only configurable option is "default_options" 107 | which is a list of default shell arguments that gets passed to xh. 108 | Example: 109 | 110 | .RS 111 | { "default_options": ["--native-tls", "--style=solarized"] } 112 | .RE 113 | .TP 114 | .IR ~/.netrc ", " ~/_netrc 115 | Auto-login information file. 116 | .TP 117 | .I ~/.config/xh/sessions 118 | Session data directory grouped by domain and port number. 119 | 120 | .SH EXAMPLES 121 | .TP 4 122 | \fBxh\fR \fIhttpbin.org/json\fR 123 | Send a GET request. 124 | .TP 125 | \fBxh\fR \fIhttpbin.org/post name=ahmed \fIage:=24\fR 126 | Send a POST request with body {"name": "ahmed", "age": 24}. 127 | .TP 128 | \fBxh\fR get \fIhttpbin.org/json id==5 sort==true\fR 129 | Send a GET request to http://httpbin.org/json?id=5&sort=true. 130 | .TP 131 | \fBxh\fR get \fIhttpbin.org/json x-api-key:12345\fR 132 | Send a GET request and include a header named X-Api-Key with value 12345. 133 | .TP 134 | echo "[1, 2, 3]" | \fBxh\fR post \fIhttpbin.org/post 135 | Send a POST request with body read from stdin. 136 | .TP 137 | \fBxh\fR put \fIhttpbin.org/put id:=49 age:=25\fR | less 138 | Send a PUT request and pipe the result to less. 139 | .TP 140 | \fBxh\fR -d \fIhttpbin.org/json\fR -o \fIres.json\fR 141 | Download and save to res.json. 142 | .TP 143 | \fBxh\fR \fIhttpbin.org/get user-agent:foobar\fR 144 | Make a request with a custom user agent. 145 | .TP 146 | \fBxhs\fR \fIexample.com\fR 147 | Make an HTTPS request to https://example.com. 148 | 149 | .SH REPORTING BUGS 150 | xh's Github issues 151 | 152 | .SH SEE ALSO 153 | \fBcurl\fR(1), \fBhttp\fR(1) 154 | 155 | HTTPie's online documentation 156 | -------------------------------------------------------------------------------- /install.ps1: -------------------------------------------------------------------------------- 1 | [Net.ServicePointManager]::SecurityProtocol = [Net.SecurityProtocolType]::Tls12 2 | 3 | $ProgressPreference = 'SilentlyContinue' 4 | $release = Invoke-RestMethod -Method Get -Uri "https://api.github.com/repos/ducaale/xh/releases/latest" 5 | $asset = $release.assets | Where-Object name -like *x86_64-pc-windows*.zip 6 | $destdir = "$home\bin" 7 | $zipfile = "$env:TEMP\$( $asset.name )" 8 | $zipfilename = [System.IO.Path]::GetFileNameWithoutExtension("$zipfile") 9 | 10 | Write-Output "Downloading: $( $asset.name )" 11 | Invoke-RestMethod -Method Get -Uri $asset.browser_download_url -OutFile $zipfile 12 | 13 | # Check if an older version of xh.exe (includes xhs.exe) exists in '$destdir', if yes, then delete it, if not then download latest zip to extract from 14 | $xhPath = "${destdir}\xh.exe" 15 | $xhsPath = "${destdir}\xhs.exe" 16 | if (Test-Path -Path $xhPath -PathType Leaf) 17 | { 18 | Write-Output "Removing previous installation of xh from $destdir" 19 | Remove-Item -r -fo $xhPath 20 | Remove-Item -r -fo $xhsPath 21 | } 22 | 23 | # Create dir for result of extraction 24 | New-Item -ItemType Directory -Path $destdir -Force | Out-Null 25 | 26 | # Decompress the zip file to the destination directory 27 | Add-Type -Assembly System.IO.Compression.FileSystem 28 | $zip = [IO.Compression.ZipFile]::OpenRead($zipfile) 29 | $entries = $zip.Entries | Where-Object { $_.FullName -like '*.exe' } 30 | $entries | ForEach-Object { [IO.Compression.ZipFileExtensions]::ExtractToFile($_, $destdir + "\" + $_.Name) } 31 | 32 | # Free the zipfile 33 | $zip.Dispose() 34 | Remove-Item -Path $zipfile 35 | 36 | # Copy xh.exe as xhs.exe into bin 37 | Copy-Item $xhPath $xhsPath 38 | 39 | # Get version from zip file name. 40 | $xhVersion = $($zipfilename.trim("xh-v -x86_64-pc-windows-msvc.zip") ) 41 | 42 | # Inform user where the executables have been put 43 | Write-Output "xh v$( $xhVersion ) has been installed to:`n - $xhPath`n - $xhsPath" 44 | 45 | # Make sure destdir is in the path 46 | $userPath = [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::User) 47 | $machinePath = [System.Environment]::GetEnvironmentVariable('Path', [System.EnvironmentVariableTarget]::Machine) 48 | 49 | # If userPath AND machinePath both do not contain bin, then add it to user path 50 | if (!($userPath.ToLower().Contains($destdir.ToLower())) -and !($machinePath.ToLower().Contains($destdir.ToLower()))) 51 | { 52 | # Update userPath 53 | $userPath = $userPath.Trim(";") + ";$destdir" 54 | 55 | # Modify PATH for new windows 56 | Write-Output "`nAdding $destdir directory to the PATH variable." 57 | [System.Environment]::SetEnvironmentVariable('Path', $userPath, [System.EnvironmentVariableTarget]::User) 58 | 59 | # Modify PATH for current terminal 60 | Write-Output "`nRefreshing current terminal's PATH for you." 61 | $Env:Path = $Env:Path.Trim(";") + ";$destdir" 62 | 63 | # Instruct how to modify PATH for other open terminals 64 | Write-Output "`nFor other terminals, restart them (or the entire IDE if they're within one).`n" 65 | 66 | } 67 | -------------------------------------------------------------------------------- /install.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh 2 | 3 | set -e 4 | 5 | if [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "x86_64" ]; then 6 | target="x86_64-apple-darwin" 7 | elif [ "$(uname -s)" = "Darwin" ] && [ "$(uname -m)" = "arm64" ]; then 8 | target="aarch64-apple-darwin" 9 | elif [ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "x86_64" ]; then 10 | target="x86_64-unknown-linux-musl" 11 | elif [ "$(uname -s)" = "Linux" ] && [ "$(uname -m)" = "aarch64" ]; then 12 | target="aarch64-unknown-linux-musl" 13 | elif [ "$(uname -s)" = "Linux" ] && ( uname -m | grep -q -e '^arm' ); then 14 | target="arm-unknown-linux-gnueabihf" 15 | else 16 | echo "Unsupported OS or architecture" 17 | exit 1 18 | fi 19 | 20 | fetch() 21 | { 22 | if which curl > /dev/null; then 23 | if [ "$#" -eq 2 ]; then curl -fL -o "$1" "$2"; else curl -fsSL "$1"; fi 24 | elif which wget > /dev/null; then 25 | if [ "$#" -eq 2 ]; then wget -O "$1" "$2"; else wget -nv -O - "$1"; fi 26 | else 27 | echo "Can't find curl or wget, can't download package" 28 | exit 1 29 | fi 30 | } 31 | 32 | echo "Detected target: $target" 33 | 34 | releases=$(fetch https://api.github.com/repos/ducaale/xh/releases/latest) 35 | url=$(echo "$releases" | grep -wo -m1 "https://.*$target.tar.gz" || true) 36 | if ! test "$url"; then 37 | echo "Could not find release info" 38 | exit 1 39 | fi 40 | 41 | echo "Downloading xh..." 42 | 43 | temp_dir=$(mktemp -dt xh.XXXXXX) 44 | trap 'rm -rf "$temp_dir"' EXIT INT TERM 45 | cd "$temp_dir" 46 | 47 | if ! fetch xh.tar.gz "$url"; then 48 | echo "Could not download tarball" 49 | exit 1 50 | fi 51 | 52 | user_bin="$HOME/.local/bin" 53 | case $PATH in 54 | *:"$user_bin":* | "$user_bin":* | *:"$user_bin") 55 | default_bin=$user_bin 56 | ;; 57 | *) 58 | default_bin='/usr/local/bin' 59 | ;; 60 | esac 61 | 62 | _read_installdir() { 63 | printf "Install location [default: %s]: " "$default_bin" 64 | read -r xh_installdir < /dev/tty 65 | xh_installdir=${xh_installdir:-$default_bin} 66 | } 67 | 68 | if [ -z "$XH_BINDIR" ]; then 69 | _read_installdir 70 | 71 | while ! test -d "$xh_installdir"; do 72 | echo "Directory $xh_installdir does not exist" 73 | _read_installdir 74 | done 75 | else 76 | xh_installdir=${XH_BINDIR} 77 | fi 78 | 79 | tar xzf xh.tar.gz 80 | 81 | if test -w "$xh_installdir" || [ -n "$XH_BINDIR" ]; then 82 | mv xh-*/xh "$xh_installdir/" 83 | ln -sf "$xh_installdir/xh" "$xh_installdir/xhs" 84 | else 85 | sudo mv xh-*/xh "$xh_installdir/" 86 | sudo ln -sf "$xh_installdir/xh" "$xh_installdir/xhs" 87 | fi 88 | 89 | echo "$("$xh_installdir"/xh -V) has been installed to:" 90 | echo " • $xh_installdir/xh" 91 | echo " • $xh_installdir/xhs" 92 | -------------------------------------------------------------------------------- /src/auth.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use anyhow::Result; 4 | use regex_lite::Regex; 5 | use reqwest::blocking::{Request, Response}; 6 | use reqwest::header::{HeaderValue, AUTHORIZATION, WWW_AUTHENTICATE}; 7 | use reqwest::StatusCode; 8 | 9 | use crate::cli::AuthType; 10 | use crate::middleware::{Context, Middleware}; 11 | use crate::netrc; 12 | use crate::utils::clone_request; 13 | 14 | #[derive(Debug, PartialEq, Eq)] 15 | pub enum Auth { 16 | Bearer(String), 17 | Basic(String, Option), 18 | Digest(String, String), 19 | } 20 | 21 | impl Auth { 22 | pub fn from_str(auth: &str, auth_type: AuthType, host: &str) -> Result { 23 | match auth_type { 24 | AuthType::Basic => { 25 | let (username, password) = parse_auth(auth, host)?; 26 | Ok(Auth::Basic(username, password)) 27 | } 28 | AuthType::Digest => { 29 | let (username, password) = parse_auth(auth, host)?; 30 | Ok(Auth::Digest( 31 | username, 32 | password.unwrap_or_else(|| "".into()), 33 | )) 34 | } 35 | AuthType::Bearer => Ok(Auth::Bearer(auth.into())), 36 | } 37 | } 38 | 39 | pub fn from_netrc(auth_type: AuthType, entry: netrc::Entry) -> Option { 40 | match auth_type { 41 | AuthType::Basic => Some(Auth::Basic(entry.login?, Some(entry.password))), 42 | AuthType::Bearer => Some(Auth::Bearer(entry.password)), 43 | AuthType::Digest => Some(Auth::Digest(entry.login?, entry.password)), 44 | } 45 | } 46 | } 47 | 48 | pub fn parse_auth(auth: &str, host: &str) -> io::Result<(String, Option)> { 49 | if let Some(cap) = Regex::new(r"^([^:]*):$").unwrap().captures(auth) { 50 | Ok((cap[1].to_string(), None)) 51 | } else if let Some(cap) = Regex::new(r"^(.+?):(.+)$").unwrap().captures(auth) { 52 | let username = cap[1].to_string(); 53 | let password = cap[2].to_string(); 54 | Ok((username, Some(password))) 55 | } else { 56 | let username = auth.to_string(); 57 | let prompt = format!("http: password for {}@{}: ", username, host); 58 | let password = rpassword::prompt_password(prompt)?; 59 | Ok((username, Some(password))) 60 | } 61 | } 62 | 63 | pub struct DigestAuthMiddleware<'a> { 64 | username: &'a str, 65 | password: &'a str, 66 | } 67 | 68 | impl<'a> DigestAuthMiddleware<'a> { 69 | pub fn new(username: &'a str, password: &'a str) -> Self { 70 | DigestAuthMiddleware { username, password } 71 | } 72 | } 73 | 74 | impl Middleware for DigestAuthMiddleware<'_> { 75 | fn handle(&mut self, mut ctx: Context, mut request: Request) -> Result { 76 | let mut response = self.next(&mut ctx, clone_request(&mut request)?)?; 77 | match response.headers().get(WWW_AUTHENTICATE) { 78 | Some(wwwauth) if response.status() == StatusCode::UNAUTHORIZED => { 79 | let mut context = digest_auth::AuthContext::new( 80 | self.username, 81 | self.password, 82 | request.url().path(), 83 | ); 84 | if let Some(cnonc) = std::env::var_os("XH_TEST_DIGEST_AUTH_CNONCE") { 85 | context.set_custom_cnonce(cnonc.to_string_lossy().to_string()); 86 | } 87 | let mut prompt = digest_auth::parse(wwwauth.to_str()?)?; 88 | let answer = prompt.respond(&context)?.to_header_string(); 89 | request 90 | .headers_mut() 91 | .insert(AUTHORIZATION, HeaderValue::from_str(&answer)?); 92 | self.print(&mut ctx, &mut response, &mut request)?; 93 | Ok(self.next(&mut ctx, request)?) 94 | } 95 | _ => Ok(response), 96 | } 97 | } 98 | } 99 | 100 | #[cfg(test)] 101 | mod tests { 102 | use super::*; 103 | 104 | #[test] 105 | fn parsing() { 106 | let expected = vec![ 107 | ("user:", ("user", None)), 108 | ("user:password", ("user", Some("password"))), 109 | ("user:pass:with:colons", ("user", Some("pass:with:colons"))), 110 | (":", ("", None)), 111 | ]; 112 | for (input, output) in expected { 113 | let (user, pass) = parse_auth(input, "").unwrap(); 114 | assert_eq!(output, (user.as_str(), pass.as_deref())); 115 | } 116 | } 117 | } 118 | -------------------------------------------------------------------------------- /src/buffer.rs: -------------------------------------------------------------------------------- 1 | //! The [`Buffer`] type is responsible for writing the program output, be it 2 | //! to a terminal or a pipe or a file. It supports colored output using 3 | //! `termcolor`'s `WriteColor` trait. 4 | //! 5 | //! It's always buffered, so `.flush()` should be called whenever no new 6 | //! output is immediately available. That's inconvenient, but improves 7 | //! throughput. 8 | //! 9 | //! We want slightly different implementations depending on the platform and 10 | //! the runtime conditions. Ansi is fast, so we go through that 11 | //! when possible, but on Windows we often need a BufferedStandardStream 12 | //! instead to use the terminal APIs. 13 | //! 14 | //! Most of this code is boilerplate. 15 | 16 | use std::{ 17 | env::var_os, 18 | io::{self, Write}, 19 | path::Path, 20 | }; 21 | 22 | use crate::{ 23 | cli::Pretty, 24 | utils::{test_default_color, test_pretend_term}, 25 | }; 26 | 27 | pub use imp::Buffer; 28 | 29 | #[cfg(not(windows))] 30 | mod imp { 31 | use std::io::{BufWriter, Write}; 32 | 33 | use termcolor::{Ansi, WriteColor}; 34 | 35 | pub struct Buffer { 36 | inner: Ansi>, 37 | terminal: bool, 38 | redirect: bool, 39 | } 40 | 41 | enum Inner { 42 | File(std::fs::File), 43 | Stdout(std::io::Stdout), 44 | Stderr(std::io::Stderr), 45 | } 46 | 47 | impl Buffer { 48 | pub fn stdout() -> Self { 49 | Self { 50 | inner: Ansi::new(BufWriter::new(Inner::Stdout(std::io::stdout()))), 51 | terminal: true, 52 | redirect: false, 53 | } 54 | } 55 | 56 | pub fn stderr() -> Self { 57 | Self { 58 | inner: Ansi::new(BufWriter::new(Inner::Stderr(std::io::stderr()))), 59 | terminal: true, 60 | redirect: false, 61 | } 62 | } 63 | 64 | pub fn redirect() -> Self { 65 | Self { 66 | inner: Ansi::new(BufWriter::new(Inner::Stdout(std::io::stdout()))), 67 | terminal: crate::test_pretend_term(), 68 | redirect: true, 69 | } 70 | } 71 | 72 | pub fn file(file: std::fs::File) -> Self { 73 | Self { 74 | inner: Ansi::new(BufWriter::new(Inner::File(file))), 75 | terminal: false, 76 | redirect: false, 77 | } 78 | } 79 | 80 | pub fn is_terminal(&self) -> bool { 81 | self.terminal 82 | } 83 | 84 | pub fn is_redirect(&self) -> bool { 85 | self.redirect 86 | } 87 | 88 | #[cfg(test)] 89 | pub fn is_stdout(&self) -> bool { 90 | matches!(self.inner.get_ref().get_ref(), Inner::Stdout(_)) 91 | } 92 | 93 | #[cfg(test)] 94 | pub fn is_stderr(&self) -> bool { 95 | matches!(self.inner.get_ref().get_ref(), Inner::Stderr(_)) 96 | } 97 | 98 | #[cfg(test)] 99 | pub fn is_file(&self) -> bool { 100 | matches!(self.inner.get_ref().get_ref(), Inner::File(_)) 101 | } 102 | } 103 | 104 | impl Write for Inner { 105 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 106 | match self { 107 | Inner::File(w) => w.write(buf), 108 | Inner::Stdout(w) => w.write(buf), 109 | Inner::Stderr(w) => w.write(buf), 110 | } 111 | } 112 | 113 | fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { 114 | match self { 115 | Inner::File(w) => w.write_all(buf), 116 | Inner::Stdout(w) => w.write_all(buf), 117 | Inner::Stderr(w) => w.write_all(buf), 118 | } 119 | } 120 | 121 | fn flush(&mut self) -> std::io::Result<()> { 122 | match self { 123 | Inner::File(w) => w.flush(), 124 | Inner::Stdout(w) => w.flush(), 125 | Inner::Stderr(w) => w.flush(), 126 | } 127 | } 128 | } 129 | 130 | impl Write for Buffer { 131 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 132 | self.inner.write(buf) 133 | } 134 | 135 | fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { 136 | // get_mut() to directly write into the BufWriter is significantly faster 137 | // https://github.com/BurntSushi/termcolor/pull/56 138 | self.inner.get_mut().write_all(buf) 139 | } 140 | 141 | fn flush(&mut self) -> std::io::Result<()> { 142 | self.inner.flush() 143 | } 144 | } 145 | 146 | impl WriteColor for Buffer { 147 | fn supports_color(&self) -> bool { 148 | true 149 | } 150 | 151 | fn set_color(&mut self, spec: &termcolor::ColorSpec) -> std::io::Result<()> { 152 | self.inner.set_color(spec) 153 | } 154 | 155 | fn reset(&mut self) -> std::io::Result<()> { 156 | self.inner.reset() 157 | } 158 | } 159 | } 160 | 161 | #[cfg(windows)] 162 | mod imp { 163 | use std::io::{BufWriter, Write}; 164 | 165 | use termcolor::{Ansi, BufferedStandardStream, ColorChoice, WriteColor}; 166 | 167 | use crate::utils::test_default_color; 168 | 169 | pub enum Buffer { 170 | // Only escape codes make sense when the output isn't going directly 171 | // to a terminal, so we use Ansi for some cases. 172 | File(Ansi>), 173 | Redirect(Ansi>), 174 | Stdout(BufferedStandardStream), 175 | Stderr(BufferedStandardStream), 176 | } 177 | 178 | impl Buffer { 179 | pub fn stdout() -> Self { 180 | Buffer::Stdout(BufferedStandardStream::stdout(if test_default_color() { 181 | ColorChoice::AlwaysAnsi 182 | } else { 183 | ColorChoice::Always 184 | })) 185 | } 186 | 187 | pub fn stderr() -> Self { 188 | Buffer::Stderr(BufferedStandardStream::stderr(if test_default_color() { 189 | ColorChoice::AlwaysAnsi 190 | } else { 191 | ColorChoice::Always 192 | })) 193 | } 194 | 195 | pub fn redirect() -> Self { 196 | Buffer::Redirect(Ansi::new(BufWriter::new(std::io::stdout()))) 197 | } 198 | 199 | pub fn file(file: std::fs::File) -> Self { 200 | Buffer::File(Ansi::new(BufWriter::new(file))) 201 | } 202 | 203 | pub fn is_terminal(&self) -> bool { 204 | matches!(self, Buffer::Stdout(_) | Buffer::Stderr(_)) 205 | } 206 | 207 | pub fn is_redirect(&self) -> bool { 208 | matches!(self, Buffer::Redirect(_)) 209 | } 210 | 211 | #[cfg(test)] 212 | pub fn is_stdout(&self) -> bool { 213 | matches!(self, Buffer::Stdout(_)) 214 | } 215 | 216 | #[cfg(test)] 217 | pub fn is_stderr(&self) -> bool { 218 | matches!(self, Buffer::Stderr(_)) 219 | } 220 | 221 | #[cfg(test)] 222 | pub fn is_file(&self) -> bool { 223 | matches!(self, Buffer::File(_)) 224 | } 225 | } 226 | 227 | impl Write for Buffer { 228 | fn write(&mut self, buf: &[u8]) -> std::io::Result { 229 | match self { 230 | Buffer::File(w) => w.write(buf), 231 | Buffer::Redirect(w) => w.write(buf), 232 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.write(buf), 233 | } 234 | } 235 | 236 | fn write_all(&mut self, buf: &[u8]) -> std::io::Result<()> { 237 | match self { 238 | Buffer::File(w) => w.get_mut().write_all(buf), 239 | Buffer::Redirect(w) => w.get_mut().write_all(buf), 240 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.write_all(buf), 241 | } 242 | } 243 | 244 | fn flush(&mut self) -> std::io::Result<()> { 245 | match self { 246 | Buffer::File(w) => w.flush(), 247 | Buffer::Redirect(w) => w.flush(), 248 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.flush(), 249 | } 250 | } 251 | } 252 | 253 | impl WriteColor for Buffer { 254 | fn supports_color(&self) -> bool { 255 | match self { 256 | Buffer::File(w) => w.supports_color(), 257 | Buffer::Redirect(w) => w.supports_color(), 258 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.supports_color(), 259 | } 260 | } 261 | 262 | fn set_color(&mut self, spec: &termcolor::ColorSpec) -> std::io::Result<()> { 263 | match self { 264 | Buffer::File(w) => w.set_color(spec), 265 | Buffer::Redirect(w) => w.set_color(spec), 266 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.set_color(spec), 267 | } 268 | } 269 | 270 | fn reset(&mut self) -> std::io::Result<()> { 271 | match self { 272 | Buffer::File(w) => w.reset(), 273 | Buffer::Redirect(w) => w.reset(), 274 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.reset(), 275 | } 276 | } 277 | 278 | fn is_synchronous(&self) -> bool { 279 | match self { 280 | Buffer::File(w) => w.is_synchronous(), 281 | Buffer::Redirect(w) => w.is_synchronous(), 282 | Buffer::Stdout(w) | Buffer::Stderr(w) => w.is_synchronous(), 283 | } 284 | } 285 | } 286 | } 287 | 288 | impl Buffer { 289 | pub fn new(download: bool, output: Option<&Path>, is_stdout_tty: bool) -> io::Result { 290 | log::trace!("is_stdout_tty: {is_stdout_tty}"); 291 | Ok(if download { 292 | Buffer::stderr() 293 | } else if let Some(output) = output { 294 | log::trace!("creating file {output:?}"); 295 | let file = std::fs::File::create(output)?; 296 | Buffer::file(file) 297 | } else if is_stdout_tty { 298 | Buffer::stdout() 299 | } else { 300 | Buffer::redirect() 301 | }) 302 | } 303 | 304 | pub fn print(&mut self, s: &str) -> io::Result<()> { 305 | self.write_all(s.as_bytes()) 306 | } 307 | 308 | pub fn guess_pretty(&self) -> Pretty { 309 | if test_default_color() { 310 | Pretty::All 311 | } else if test_pretend_term() { 312 | Pretty::Format 313 | } else if self.is_terminal() { 314 | // Based on termcolor's logic for ColorChoice::Auto 315 | if cfg!(test) { 316 | Pretty::All 317 | } else if var_os("NO_COLOR").is_some_and(|val| !val.is_empty()) { 318 | Pretty::Format 319 | } else { 320 | match var_os("TERM") { 321 | Some(term) if term == "dumb" => Pretty::Format, 322 | Some(_) => Pretty::All, 323 | None if cfg!(windows) => Pretty::All, 324 | None => Pretty::Format, 325 | } 326 | } 327 | } else { 328 | Pretty::None 329 | } 330 | } 331 | } 332 | -------------------------------------------------------------------------------- /src/content_disposition.rs: -------------------------------------------------------------------------------- 1 | use percent_encoding::percent_decode_str; 2 | 3 | /// Parse filename from Content-Disposition header 4 | /// Prioritizes filename* parameter if present, otherwise uses filename parameter 5 | pub fn parse_filename_from_content_disposition(content_disposition: &str) -> Option { 6 | let parts: Vec<&str> = content_disposition 7 | .split(';') 8 | .map(|part| part.trim()) 9 | .collect(); 10 | 11 | // First try to find filename* parameter 12 | for part in parts.iter() { 13 | if let Some(value) = part.strip_prefix("filename*=") { 14 | if let Some(filename) = parse_encoded_filename(value) { 15 | return Some(filename); 16 | } 17 | } 18 | } 19 | 20 | // If filename* is not found or parsing failed, try regular filename parameter 21 | for part in parts { 22 | if let Some(value) = part.strip_prefix("filename=") { 23 | return parse_regular_filename(value); 24 | } 25 | } 26 | 27 | None 28 | } 29 | 30 | /// Parse regular filename parameter 31 | /// Handles both quoted and unquoted filenames 32 | fn parse_regular_filename(filename: &str) -> Option { 33 | // Content-Disposition: attachment; filename="file with \"quotes\".txt" // This won't occur 34 | // Content-Disposition: attachment; filename*=UTF-8''file%20with%20quotes.txt // This is the actual practice 35 | // 36 | // We don't need to handle escaped characters in Content-Disposition header parsing because: 37 | // 38 | // It's not a standard practice 39 | // It rarely occurs in real-world scenarios 40 | // When filenames contain special characters, they should use the filename* parameter 41 | 42 | // Remove quotes if present 43 | let filename = if filename.starts_with('"') && filename.ends_with('"') && filename.len() >= 2 { 44 | &filename[1..(filename.len() - 1)] 45 | } else { 46 | filename 47 | }; 48 | 49 | if filename.is_empty() { 50 | return None; 51 | } 52 | 53 | Some(filename.to_string()) 54 | } 55 | 56 | /// Parse RFC 5987 encoded filename (filename*) 57 | /// Format: charset'language'encoded-value 58 | fn parse_encoded_filename(content: &str) -> Option { 59 | // Remove "filename*=" prefix 60 | 61 | // According to RFC 5987, format should be: charset'language'encoded-value 62 | let parts: Vec<&str> = content.splitn(3, '\'').collect(); 63 | if parts.len() != 3 { 64 | return None; 65 | } 66 | let charset = parts[0]; 67 | let encoded_filename = parts[2]; 68 | 69 | // Percent-decode the encoded filename into bytes. 70 | let decoded_bytes = percent_decode_str(encoded_filename).collect::>(); 71 | 72 | if charset.eq_ignore_ascii_case("UTF-8") { 73 | if let Ok(decoded_str) = String::from_utf8(decoded_bytes) { 74 | return Some(decoded_str); 75 | } 76 | } else if charset.eq_ignore_ascii_case("ISO-8859-1") { 77 | // RFC 5987 says to use ISO/IEC 8859-1:1998. 78 | // But Firefox and Chromium decode %99 as ™ so they're actually using 79 | // Windows-1252. This mixup is common on the web. 80 | // This affects the 0x80-0x9F range. According to ISO 8859-1 those are 81 | // control characters. According to Windows-1252 most of them are 82 | // printable characters. 83 | // They agree on all the other characters, and filenames shouldn't have 84 | // control characters, so Windows-1252 makes sense. 85 | if let Some(decoded_str) = encoding_rs::WINDOWS_1252 86 | .decode_without_bom_handling_and_without_replacement(&decoded_bytes) 87 | { 88 | return Some(decoded_str.into_owned()); 89 | } 90 | } else { 91 | // Unknown charset. As a fallback, try interpreting as UTF-8. 92 | // Firefox also does this. 93 | // Chromium makes up its own filename. (Even if `filename=` is present.) 94 | if let Ok(decoded_str) = String::from_utf8(decoded_bytes) { 95 | return Some(decoded_str); 96 | } 97 | } 98 | 99 | None 100 | } 101 | 102 | #[cfg(test)] 103 | mod tests { 104 | use super::*; 105 | 106 | #[test] 107 | fn test_simple_filename() { 108 | let header = r#"attachment; filename="example.pdf""#; 109 | assert_eq!( 110 | parse_filename_from_content_disposition(header), 111 | Some("example.pdf".to_string()) 112 | ); 113 | } 114 | 115 | #[test] 116 | fn test_filename_without_quotes() { 117 | let header = "attachment; filename=example.pdf"; 118 | assert_eq!( 119 | parse_filename_from_content_disposition(header), 120 | Some("example.pdf".to_string()) 121 | ); 122 | } 123 | 124 | #[test] 125 | fn test_encoded_filename() { 126 | // UTF-8 encoded Chinese filename "测试.pdf" 127 | let header = "attachment; filename*=UTF-8''%E6%B5%8B%E8%AF%95.pdf"; 128 | assert_eq!( 129 | parse_filename_from_content_disposition(header), 130 | Some("测试.pdf".to_string()) 131 | ); 132 | } 133 | 134 | #[test] 135 | fn test_both_filenames() { 136 | // When both filename and filename* are present, filename* should be preferred 137 | let header = 138 | r#"attachment; filename="fallback.pdf"; filename*=UTF-8''%E6%B5%8B%E8%AF%95.pdf"#; 139 | assert_eq!( 140 | parse_filename_from_content_disposition(header), 141 | Some("测试.pdf".to_string()) 142 | ); 143 | } 144 | #[test] 145 | fn test_decode_with_windows_1252() { 146 | let header = "content-disposition: attachment; filename*=iso-8859-1'en'a%99b"; 147 | assert_eq!( 148 | parse_filename_from_content_disposition(header), 149 | Some("a™b".to_string()) 150 | ); 151 | } 152 | 153 | #[test] 154 | fn test_both_filenames_with_bad_format() { 155 | // When both filename and filename* are present, filename* with bad format, filename should be used 156 | let header = r#"attachment; filename="fallback.pdf"; filename*=UTF-8'bad_format.pdf"#; 157 | assert_eq!( 158 | parse_filename_from_content_disposition(header), 159 | Some("fallback.pdf".to_string()) 160 | ); 161 | } 162 | 163 | #[test] 164 | fn test_no_filename() { 165 | let header = "attachment"; 166 | assert_eq!(parse_filename_from_content_disposition(header), None); 167 | } 168 | 169 | #[test] 170 | fn test_iso_8859_1() { 171 | let header = "attachment;filename*=iso-8859-1'en'%A3%20rates"; 172 | assert_eq!( 173 | parse_filename_from_content_disposition(header), 174 | Some("£ rates".to_string()) 175 | ); 176 | } 177 | 178 | #[test] 179 | fn test_bad_encoding_fallback_to_utf8() { 180 | let header = "attachment;filename*=UTF-16''%E6%B5%8B%E8%AF%95.pdf"; 181 | assert_eq!( 182 | parse_filename_from_content_disposition(header), 183 | Some("测试.pdf".to_string()) 184 | ); 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /src/download.rs: -------------------------------------------------------------------------------- 1 | use std::fs::{self, File, OpenOptions}; 2 | use std::io::{self, ErrorKind, IsTerminal}; 3 | use std::path::{Path, PathBuf}; 4 | use std::time::Instant; 5 | 6 | use crate::content_disposition; 7 | use crate::decoder::{decompress, get_compression_type}; 8 | use crate::utils::{copy_largebuf, test_pretend_term, HeaderValueExt}; 9 | use anyhow::{anyhow, Context, Result}; 10 | use indicatif::{HumanBytes, ProgressBar, ProgressStyle}; 11 | use mime2ext::mime2ext; 12 | use regex_lite::Regex; 13 | use reqwest::{ 14 | blocking::Response, 15 | header::{HeaderMap, CONTENT_DISPOSITION, CONTENT_LENGTH, CONTENT_RANGE, CONTENT_TYPE}, 16 | StatusCode, 17 | }; 18 | 19 | fn get_content_length(headers: &HeaderMap) -> Option { 20 | headers 21 | .get(CONTENT_LENGTH) 22 | .and_then(|v| v.to_str().ok()) 23 | .and_then(|s| s.parse::().ok()) 24 | } 25 | 26 | // This function is system-agnostic, so it's ok for it to use Strings instead 27 | // of PathBufs 28 | fn get_file_name(response: &Response, orig_url: &reqwest::Url) -> String { 29 | fn from_header(response: &Response) -> Option { 30 | let header = response 31 | .headers() 32 | .get(CONTENT_DISPOSITION)? 33 | .to_utf8_str() 34 | .ok()?; 35 | content_disposition::parse_filename_from_content_disposition(header) 36 | } 37 | 38 | fn from_url(url: &reqwest::Url) -> Option { 39 | let last_seg = url 40 | .path_segments()? 41 | .rev() 42 | .find(|segment| !segment.is_empty())?; 43 | Some(last_seg.to_string()) 44 | } 45 | 46 | fn guess_extension(response: &Response) -> Option<&'static str> { 47 | let mimetype = response.headers().get(CONTENT_TYPE)?.to_str().ok()?; 48 | mime2ext(mimetype) 49 | } 50 | 51 | let filename = from_header(response) 52 | .or_else(|| from_url(orig_url)) 53 | .unwrap_or_else(|| "index".to_string()); 54 | 55 | let filename = sanitize_filename::sanitize_with_options( 56 | &filename, 57 | sanitize_filename::Options { 58 | replacement: "_", 59 | ..Default::default() 60 | }, 61 | ); 62 | 63 | let mut filename = filename.trim().trim_start_matches('.').to_string(); 64 | 65 | if !filename.contains('.') { 66 | if let Some(extension) = guess_extension(response) { 67 | filename.push('.'); 68 | filename.push_str(extension); 69 | } 70 | } 71 | 72 | filename 73 | } 74 | 75 | pub fn get_file_size(path: Option<&Path>) -> Option { 76 | Some(fs::metadata(path?).ok()?.len()) 77 | } 78 | 79 | /// Find a file name that doesn't exist yet. 80 | fn open_new_file(file_name: PathBuf) -> io::Result<(PathBuf, File)> { 81 | fn try_open_new(file_name: &Path) -> io::Result> { 82 | match OpenOptions::new() 83 | .write(true) 84 | .create_new(true) 85 | .open(file_name) 86 | { 87 | Ok(file) => Ok(Some(file)), 88 | Err(err) if err.kind() == ErrorKind::AlreadyExists => Ok(None), 89 | Err(err) => Err(err), 90 | } 91 | } 92 | if let Some(file) = try_open_new(&file_name)? { 93 | return Ok((file_name, file)); 94 | } 95 | for suffix in 1..u32::MAX { 96 | let candidate = { 97 | let mut candidate = file_name.clone().into_os_string(); 98 | candidate.push(format!("-{}", suffix)); 99 | PathBuf::from(candidate) 100 | }; 101 | if let Some(file) = try_open_new(&candidate)? { 102 | return Ok((candidate, file)); 103 | } 104 | } 105 | panic!("Could not create file after unreasonable number of attempts"); 106 | } 107 | 108 | // https://github.com/httpie/httpie/blob/84c7327057/httpie/downloads.py#L44 109 | // https://tools.ietf.org/html/rfc7233#section-4.2 110 | fn total_for_content_range(header: &str, expected_start: u64) -> Result { 111 | let re_range = Regex::new(concat!( 112 | r"^bytes (?P\d+)-(?P\d+)", 113 | r"/(?:\*|(?P\d+))$" 114 | )) 115 | .unwrap(); 116 | let caps = re_range 117 | .captures(header) 118 | // Could happen if header uses unit other than bytes 119 | .ok_or_else(|| anyhow!("Can't parse Content-Range header, can't resume download"))?; 120 | let first_byte_pos: u64 = caps 121 | .name("first_byte_pos") 122 | .unwrap() 123 | .as_str() 124 | .parse() 125 | .context("Can't parse Content-Range first_byte_pos")?; 126 | let last_byte_pos: u64 = caps 127 | .name("last_byte_pos") 128 | .unwrap() 129 | .as_str() 130 | .parse() 131 | .context("Can't parse Content-Range last_byte_pos")?; 132 | let complete_length: Option = caps 133 | .name("complete_length") 134 | .map(|num| { 135 | num.as_str() 136 | .parse() 137 | .context("Can't parse Content-Range complete_length") 138 | }) 139 | .transpose()?; 140 | // Note that last_byte_pos must be strictly less than complete_length 141 | // If first_byte_pos == last_byte_pos exactly one byte is sent 142 | if first_byte_pos > last_byte_pos { 143 | return Err(anyhow!("Invalid Content-Range: {:?}", header)); 144 | } 145 | if let Some(complete_length) = complete_length { 146 | if last_byte_pos >= complete_length { 147 | return Err(anyhow!("Invalid Content-Range: {:?}", header)); 148 | } 149 | if complete_length != last_byte_pos + 1 { 150 | return Err(anyhow!("Content-Range has wrong end: {:?}", header)); 151 | } 152 | } 153 | if expected_start != first_byte_pos { 154 | return Err(anyhow!("Content-Range has wrong start: {:?}", header)); 155 | } 156 | Ok(last_byte_pos + 1) 157 | } 158 | 159 | const BAR_TEMPLATE: &str = 160 | "{spinner:.green} {percent}% [{wide_bar:.cyan/blue}] {bytes} {bytes_per_sec} ETA {eta}"; 161 | const UNCOLORED_BAR_TEMPLATE: &str = 162 | "{spinner} {percent}% [{wide_bar}] {bytes} {bytes_per_sec} ETA {eta}"; 163 | const SPINNER_TEMPLATE: &str = "{spinner:.green} {bytes} {bytes_per_sec} {wide_msg}"; 164 | const UNCOLORED_SPINNER_TEMPLATE: &str = "{spinner} {bytes} {bytes_per_sec} {wide_msg}"; 165 | 166 | pub fn download_file( 167 | mut response: Response, 168 | file_name: Option, 169 | // If we fall back on taking the filename from the URL it has to be the 170 | // original URL, before redirects. That's less surprising and matches 171 | // HTTPie. Hence this argument. 172 | orig_url: &reqwest::Url, 173 | mut resume: Option, 174 | color: bool, 175 | quiet: bool, 176 | ) -> Result<()> { 177 | if resume.is_some() && response.status() != StatusCode::PARTIAL_CONTENT { 178 | resume = None; 179 | } 180 | 181 | let mut buffer: Box; 182 | let dest_name: PathBuf; 183 | 184 | if let Some(file_name) = file_name { 185 | let mut open_opts = OpenOptions::new(); 186 | open_opts.write(true).create(true); 187 | if resume.is_some() { 188 | open_opts.append(true); 189 | } else { 190 | open_opts.truncate(true); 191 | } 192 | 193 | dest_name = file_name; 194 | buffer = Box::new(open_opts.open(&dest_name)?); 195 | } else if test_pretend_term() || io::stdout().is_terminal() { 196 | let (new_name, handle) = open_new_file(get_file_name(&response, orig_url).into())?; 197 | dest_name = new_name; 198 | buffer = Box::new(handle); 199 | } else { 200 | dest_name = "".into(); 201 | buffer = Box::new(io::stdout()); 202 | } 203 | 204 | let starting_length: u64; 205 | let total_length: Option; 206 | if let Some(resume) = resume { 207 | let header = response 208 | .headers() 209 | .get(CONTENT_RANGE) 210 | .ok_or_else(|| anyhow!("Missing Content-Range header"))? 211 | .to_str() 212 | .map_err(|_| anyhow!("Bad Content-Range header"))?; 213 | starting_length = resume; 214 | total_length = Some(total_for_content_range(header, starting_length)?); 215 | } else { 216 | starting_length = 0; 217 | total_length = get_content_length(response.headers()); 218 | } 219 | 220 | let starting_time = Instant::now(); 221 | 222 | let pb = if quiet { 223 | None 224 | } else if let Some(total_length) = total_length { 225 | eprintln!( 226 | "Downloading {} to {:?}", 227 | HumanBytes(total_length - starting_length), 228 | dest_name 229 | ); 230 | let style = ProgressStyle::default_bar() 231 | .template(if color { 232 | BAR_TEMPLATE 233 | } else { 234 | UNCOLORED_BAR_TEMPLATE 235 | })? 236 | .progress_chars("#>-"); 237 | Some(ProgressBar::new(total_length).with_style(style)) 238 | } else { 239 | eprintln!("Downloading to {:?}", dest_name); 240 | let style = ProgressStyle::default_bar().template(if color { 241 | SPINNER_TEMPLATE 242 | } else { 243 | UNCOLORED_SPINNER_TEMPLATE 244 | })?; 245 | Some(ProgressBar::new_spinner().with_style(style)) 246 | }; 247 | if let Some(pb) = &pb { 248 | pb.set_position(starting_length); 249 | pb.reset_eta(); 250 | } 251 | 252 | match pb { 253 | Some(ref pb) => { 254 | let compression_type = get_compression_type(response.headers()); 255 | copy_largebuf( 256 | &mut decompress(&mut pb.wrap_read(response), compression_type), 257 | &mut buffer, 258 | false, 259 | )?; 260 | let downloaded_length = pb.position() - starting_length; 261 | pb.finish_and_clear(); 262 | let time_taken = starting_time.elapsed(); 263 | if !time_taken.is_zero() { 264 | eprintln!( 265 | "Done. {} in {:.5}s ({}/s)", 266 | HumanBytes(downloaded_length), 267 | time_taken.as_secs_f64(), 268 | HumanBytes((downloaded_length as f64 / time_taken.as_secs_f64()) as u64) 269 | ); 270 | } else { 271 | eprintln!("Done. {}", HumanBytes(downloaded_length)); 272 | } 273 | } 274 | None => { 275 | let compression_type = get_compression_type(response.headers()); 276 | copy_largebuf( 277 | &mut decompress(&mut response, compression_type), 278 | &mut buffer, 279 | false, 280 | )?; 281 | } 282 | } 283 | 284 | Ok(()) 285 | } 286 | 287 | #[cfg(test)] 288 | mod tests { 289 | use super::*; 290 | 291 | #[test] 292 | fn content_range_parsing() { 293 | let expected = vec![ 294 | (2, "bytes 2-5/6", Some(6)), 295 | (2, "bytes 2-5/*", Some(6)), 296 | (5, "bytes 5-5/6", Some(6)), 297 | (2, "bytes 3-5/6", None), 298 | (2, "bytes 1-5/6", None), 299 | (2, "bytes 2-4/6", None), 300 | (2, "bytes 2-6/6", None), 301 | ]; 302 | for (start, header, result) in expected { 303 | assert_eq!(total_for_content_range(header, start).ok(), result); 304 | } 305 | } 306 | } 307 | -------------------------------------------------------------------------------- /src/error_reporting.rs: -------------------------------------------------------------------------------- 1 | use std::process::ExitCode; 2 | 3 | pub(crate) fn additional_messages(err: &anyhow::Error, native_tls: bool) -> Vec { 4 | let mut msgs = Vec::new(); 5 | 6 | #[cfg(feature = "rustls")] 7 | msgs.extend(format_rustls_error(err)); 8 | 9 | if native_tls && err.root_cause().to_string() == "invalid minimum TLS version for backend" { 10 | msgs.push("Try running without the --native-tls flag.".into()); 11 | } 12 | 13 | msgs 14 | } 15 | 16 | /// Format certificate expired/not valid yet messages. By default these print 17 | /// human-unfriendly Unix timestamps. 18 | /// 19 | /// Other rustls error messages (e.g. wrong host) are readable enough. 20 | #[cfg(feature = "rustls")] 21 | fn format_rustls_error(err: &anyhow::Error) -> Option { 22 | use humantime::format_duration; 23 | use rustls::pki_types::UnixTime; 24 | use rustls::CertificateError; 25 | use time::OffsetDateTime; 26 | 27 | // Multiple layers of io::Error for some reason? 28 | // This may be fragile 29 | let err = err.root_cause().downcast_ref::()?; 30 | let err = err.get_ref()?.downcast_ref::()?; 31 | let err = err.get_ref()?.downcast_ref::()?; 32 | let rustls::Error::InvalidCertificate(err) = err else { 33 | return None; 34 | }; 35 | 36 | fn conv_time(unix_time: &UnixTime) -> Option { 37 | OffsetDateTime::from_unix_timestamp(unix_time.as_secs() as i64).ok() 38 | } 39 | 40 | match err { 41 | CertificateError::ExpiredContext { time, not_after } => { 42 | let time = conv_time(time)?; 43 | let not_after = conv_time(not_after)?; 44 | let diff = format_duration((time - not_after).try_into().ok()?); 45 | Some(format!( 46 | "Certificate not valid after {not_after} ({diff} ago).", 47 | )) 48 | } 49 | CertificateError::NotValidYetContext { time, not_before } => { 50 | let time = conv_time(time)?; 51 | let not_before = conv_time(not_before)?; 52 | let diff = format_duration((not_before - time).try_into().ok()?); 53 | Some(format!( 54 | "Certificate not valid before {not_before} ({diff} from now).", 55 | )) 56 | } 57 | _ => None, 58 | } 59 | } 60 | 61 | pub(crate) fn exit_code(err: &anyhow::Error) -> ExitCode { 62 | if let Some(err) = err.downcast_ref::() { 63 | if err.is_timeout() { 64 | return ExitCode::from(2); 65 | } 66 | } 67 | 68 | if err 69 | .downcast_ref::() 70 | .is_some() 71 | { 72 | return ExitCode::from(6); 73 | } 74 | 75 | ExitCode::FAILURE 76 | } 77 | -------------------------------------------------------------------------------- /src/formatting/headers.rs: -------------------------------------------------------------------------------- 1 | use std::io::Result; 2 | 3 | use reqwest::{ 4 | header::{HeaderMap, HeaderName, HeaderValue}, 5 | Method, StatusCode, Version, 6 | }; 7 | use syntect::highlighting::Theme; 8 | use termcolor::WriteColor; 9 | use url::Url; 10 | 11 | use crate::utils::HeaderValueExt; 12 | 13 | super::palette::palette! { 14 | struct HeaderPalette { 15 | http_keyword: ["keyword.other.http"], 16 | http_separator: ["punctuation.separator.http"], 17 | http_version: ["constant.numeric.http"], 18 | method: ["keyword.control.http"], 19 | path: ["const.language.http"], 20 | status_code: ["constant.numeric.http"], 21 | status_reason: ["keyword.reason.http"], 22 | header_name: ["source.http", "http.requestheaders", "support.variable.http"], 23 | header_colon: ["source.http", "http.requestheaders", "punctuation.separator.http"], 24 | header_value: ["source.http", "http.requestheaders", "string.other.http"], 25 | error: ["error"], 26 | } 27 | } 28 | 29 | macro_rules! set_color { 30 | ($self:ident, $color:ident) => { 31 | if let Some(ref palette) = $self.palette { 32 | $self.output.set_color(&palette.$color) 33 | } else { 34 | Ok(()) 35 | } 36 | }; 37 | } 38 | 39 | pub(crate) struct HeaderFormatter<'a, W: WriteColor> { 40 | output: &'a mut W, 41 | palette: Option, 42 | is_terminal: bool, 43 | sort_headers: bool, 44 | } 45 | 46 | impl<'a, W: WriteColor> HeaderFormatter<'a, W> { 47 | pub(crate) fn new( 48 | output: &'a mut W, 49 | theme: Option<&Theme>, 50 | is_terminal: bool, 51 | sort_headers: bool, 52 | ) -> Self { 53 | Self { 54 | palette: theme.map(HeaderPalette::from), 55 | output, 56 | is_terminal, 57 | sort_headers, 58 | } 59 | } 60 | 61 | fn print(&mut self, text: &str) -> Result<()> { 62 | self.output.write_all(text.as_bytes()) 63 | } 64 | 65 | fn print_plain(&mut self, text: &str) -> Result<()> { 66 | set_color!(self, default)?; 67 | self.print(text) 68 | } 69 | 70 | pub(crate) fn print_request_headers( 71 | &mut self, 72 | method: &Method, 73 | url: &Url, 74 | version: Version, 75 | headers: &HeaderMap, 76 | ) -> Result<()> { 77 | set_color!(self, method)?; 78 | self.print(method.as_str())?; 79 | 80 | self.print_plain(" ")?; 81 | 82 | set_color!(self, path)?; 83 | self.print(url.path())?; 84 | if let Some(query) = url.query() { 85 | self.print("?")?; 86 | self.print(query)?; 87 | } 88 | 89 | self.print_plain(" ")?; 90 | self.print_http_version(version)?; 91 | 92 | self.print_plain("\n")?; 93 | self.print_headers(headers, version)?; 94 | 95 | if self.palette.is_some() { 96 | self.output.reset()?; 97 | } 98 | Ok(()) 99 | } 100 | 101 | pub(crate) fn print_response_headers( 102 | &mut self, 103 | version: Version, 104 | status: StatusCode, 105 | reason_phrase: &str, 106 | headers: &HeaderMap, 107 | ) -> Result<()> { 108 | self.print_http_version(version)?; 109 | 110 | self.print_plain(" ")?; 111 | 112 | set_color!(self, status_code)?; 113 | self.print(status.as_str())?; 114 | 115 | self.print_plain(" ")?; 116 | 117 | set_color!(self, status_reason)?; 118 | self.print(reason_phrase)?; 119 | 120 | self.print_plain("\n")?; 121 | 122 | self.print_headers(headers, version)?; 123 | 124 | if self.palette.is_some() { 125 | self.output.reset()?; 126 | } 127 | Ok(()) 128 | } 129 | 130 | fn print_http_version(&mut self, version: Version) -> Result<()> { 131 | let version = format!("{version:?}"); 132 | let version = version.strip_prefix("HTTP/").unwrap_or(&version); 133 | 134 | set_color!(self, http_keyword)?; 135 | self.print("HTTP")?; 136 | set_color!(self, http_separator)?; 137 | self.print("/")?; 138 | set_color!(self, http_version)?; 139 | self.print(version)?; 140 | 141 | Ok(()) 142 | } 143 | 144 | fn print_headers(&mut self, headers: &HeaderMap, version: Version) -> Result<()> { 145 | let as_titlecase = match version { 146 | Version::HTTP_09 | Version::HTTP_10 | Version::HTTP_11 => true, 147 | Version::HTTP_2 | Version::HTTP_3 => false, 148 | _ => false, 149 | }; 150 | let mut headers: Vec<(&HeaderName, &HeaderValue)> = headers.iter().collect(); 151 | if self.sort_headers { 152 | headers.sort_by_key(|(name, _)| name.as_str()); 153 | } 154 | 155 | let mut namebuf = String::with_capacity(64); 156 | for (name, value) in headers { 157 | let key = if as_titlecase { 158 | titlecase_header(name, &mut namebuf) 159 | } else { 160 | name.as_str() 161 | }; 162 | 163 | set_color!(self, header_name)?; 164 | self.print(key)?; 165 | set_color!(self, header_colon)?; 166 | self.print(":")?; 167 | self.print_plain(" ")?; 168 | 169 | match value.to_ascii_or_latin1() { 170 | Ok(ascii) => { 171 | set_color!(self, header_value)?; 172 | self.print(ascii)?; 173 | } 174 | Err(bad) => { 175 | const FAQ_URL: &str = 176 | "https://github.com/ducaale/xh/blob/master/FAQ.md#header-value-encoding"; 177 | 178 | let mut latin1 = bad.latin1(); 179 | if self.is_terminal { 180 | latin1 = sanitize_header_value(&latin1); 181 | } 182 | set_color!(self, error)?; 183 | self.print(&latin1)?; 184 | 185 | if let Some(utf8) = bad.utf8() { 186 | set_color!(self, default)?; 187 | if self.palette.is_some() && super::supports_hyperlinks() { 188 | self.print(" (")?; 189 | self.print(&super::create_hyperlink("UTF-8", FAQ_URL))?; 190 | self.print(": ")?; 191 | } else { 192 | self.print(" (UTF-8: ")?; 193 | } 194 | 195 | set_color!(self, header_value)?; 196 | // We could escape these as well but latin1 has a much higher chance 197 | // to contain control characters because: 198 | // - ~14% of the possible latin1 codepoints are control characters, 199 | // versus <0.1% for UTF-8. 200 | // - The latin1 text may not be intended as latin1, but if it's valid 201 | // as UTF-8 then chances are that it really is UTF-8. 202 | // We should revisit this if we come up with a general policy for 203 | // escaping control characters, not just in headers. 204 | self.print(utf8)?; 205 | self.print_plain(")")?; 206 | } 207 | } 208 | } 209 | self.print_plain("\n")?; 210 | } 211 | 212 | Ok(()) 213 | } 214 | } 215 | 216 | fn titlecase_header<'b>(name: &HeaderName, buffer: &'b mut String) -> &'b str { 217 | let name = name.as_str(); 218 | buffer.clear(); 219 | buffer.reserve(name.len()); 220 | // Ought to be equivalent to how hyper does it 221 | // https://github.com/hyperium/hyper/blob/f46b175bf71b202fbb907c4970b5743881b891e1/src/proto/h1/role.rs#L1332 222 | // Header names are ASCII so operating on char or u8 is equivalent 223 | let mut prev = '-'; 224 | for mut c in name.chars() { 225 | if prev == '-' { 226 | c.make_ascii_uppercase(); 227 | } 228 | buffer.push(c); 229 | prev = c; 230 | } 231 | buffer 232 | } 233 | 234 | /// Escape control characters. Firefox uses Unicode replacement characters, 235 | /// that seems like a good choice. 236 | /// 237 | /// Header values can't contain ASCII control characters (like newlines) 238 | /// but if misencoded they frequently contain latin1 control characters. 239 | /// What we do here might not make sense for other strings. 240 | fn sanitize_header_value(value: &str) -> String { 241 | const REPLACEMENT_CHARACTER: &str = "\u{FFFD}"; 242 | value.replace(char::is_control, REPLACEMENT_CHARACTER) 243 | } 244 | 245 | #[cfg(test)] 246 | mod tests { 247 | use indoc::indoc; 248 | 249 | use super::*; 250 | 251 | #[test] 252 | fn test_header_casing() { 253 | let mut headers = HeaderMap::new(); 254 | headers.insert("ab-cd", "0".parse().unwrap()); 255 | headers.insert("-cd", "0".parse().unwrap()); 256 | headers.insert("-", "0".parse().unwrap()); 257 | headers.insert("ab-%c", "0".parse().unwrap()); 258 | headers.insert("A-b--C", "0".parse().unwrap()); 259 | 260 | let mut buf = termcolor::Ansi::new(Vec::new()); 261 | let mut formatter = HeaderFormatter::new(&mut buf, None, false, false); 262 | formatter.print_headers(&headers, Version::HTTP_11).unwrap(); 263 | let buf = buf.into_inner(); 264 | assert_eq!( 265 | buf, 266 | indoc! {b" 267 | Ab-Cd: 0 268 | -Cd: 0 269 | -: 0 270 | Ab-%c: 0 271 | A-B--C: 0 272 | " 273 | } 274 | ); 275 | 276 | let mut buf = termcolor::Ansi::new(Vec::new()); 277 | let mut formatter = HeaderFormatter::new(&mut buf, None, false, false); 278 | formatter.print_headers(&headers, Version::HTTP_2).unwrap(); 279 | let buf = buf.into_inner(); 280 | assert_eq!( 281 | buf, 282 | indoc! {b" 283 | ab-cd: 0 284 | -cd: 0 285 | -: 0 286 | ab-%c: 0 287 | a-b--c: 0 288 | " 289 | } 290 | ); 291 | } 292 | } 293 | -------------------------------------------------------------------------------- /src/formatting/mod.rs: -------------------------------------------------------------------------------- 1 | use std::{ 2 | io::{self, Write}, 3 | sync::OnceLock, 4 | }; 5 | 6 | use syntect::dumps::from_binary; 7 | use syntect::easy::HighlightLines; 8 | use syntect::highlighting::ThemeSet; 9 | use syntect::parsing::SyntaxSet; 10 | use syntect::util::LinesWithEndings; 11 | use termcolor::WriteColor; 12 | 13 | use crate::{buffer::Buffer, cli::Theme}; 14 | 15 | pub(crate) mod headers; 16 | pub(crate) mod palette; 17 | 18 | pub fn get_json_formatter(indent_level: usize) -> jsonxf::Formatter { 19 | let mut fmt = jsonxf::Formatter::pretty_printer(); 20 | fmt.indent = " ".repeat(indent_level); 21 | fmt.record_separator = String::from("\n\n"); 22 | fmt.eager_record_separators = true; 23 | fmt 24 | } 25 | 26 | /// Format a JSON value using serde. Unlike jsonxf this decodes escaped Unicode values. 27 | /// 28 | /// Note that if parsing fails this function will stop midway through and return an error. 29 | /// It should only be used with known-valid JSON. 30 | pub fn serde_json_format(indent_level: usize, text: &str, write: impl Write) -> io::Result<()> { 31 | let indent = " ".repeat(indent_level); 32 | let formatter = serde_json::ser::PrettyFormatter::with_indent(indent.as_bytes()); 33 | let mut serializer = serde_json::Serializer::with_formatter(write, formatter); 34 | let mut deserializer = serde_json::Deserializer::from_str(text); 35 | serde_transcode::transcode(&mut deserializer, &mut serializer)?; 36 | Ok(()) 37 | } 38 | 39 | pub(crate) static THEMES: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { 40 | from_binary(include_bytes!(concat!( 41 | env!("OUT_DIR"), 42 | "/themepack.themedump" 43 | ))) 44 | }); 45 | static PS_BASIC: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { 46 | from_binary(include_bytes!(concat!(env!("OUT_DIR"), "/basic.packdump"))) 47 | }); 48 | static PS_LARGE: once_cell::sync::Lazy = once_cell::sync::Lazy::new(|| { 49 | from_binary(include_bytes!(concat!(env!("OUT_DIR"), "/large.packdump"))) 50 | }); 51 | 52 | pub struct Highlighter<'a> { 53 | highlighter: HighlightLines<'static>, 54 | syntax_set: &'static SyntaxSet, 55 | out: &'a mut Buffer, 56 | } 57 | 58 | /// A wrapper around a [`Buffer`] to add syntax highlighting when printing. 59 | impl<'a> Highlighter<'a> { 60 | pub fn new(syntax: &'static str, theme: Theme, out: &'a mut Buffer) -> Self { 61 | let syntax_set: &SyntaxSet = match syntax { 62 | "json" => &PS_BASIC, 63 | _ => &PS_LARGE, 64 | }; 65 | let syntax = syntax_set 66 | .find_syntax_by_extension(syntax) 67 | .expect("syntax not found"); 68 | Self { 69 | highlighter: HighlightLines::new(syntax, theme.as_syntect_theme()), 70 | syntax_set, 71 | out, 72 | } 73 | } 74 | 75 | /// Write a single piece of highlighted text. 76 | /// May return a [`io::ErrorKind::Other`] when there is a problem 77 | /// during highlighting. 78 | pub fn highlight(&mut self, text: &str) -> io::Result<()> { 79 | for line in LinesWithEndings::from(text) { 80 | for (style, component) in self 81 | .highlighter 82 | .highlight_line(line, self.syntax_set) 83 | .map_err(io::Error::other)? 84 | { 85 | self.out.set_color(&convert_style(style))?; 86 | write!(self.out, "{}", component)?; 87 | } 88 | } 89 | Ok(()) 90 | } 91 | 92 | pub fn highlight_bytes(&mut self, line: &[u8]) -> io::Result<()> { 93 | self.highlight(&String::from_utf8_lossy(line)) 94 | } 95 | 96 | pub fn flush(&mut self) -> io::Result<()> { 97 | self.out.flush() 98 | } 99 | } 100 | 101 | impl Drop for Highlighter<'_> { 102 | fn drop(&mut self) { 103 | // This is just a best-effort attempt to restore the terminal, failure can be ignored 104 | let _ = self.out.reset(); 105 | } 106 | } 107 | 108 | fn convert_style(style: syntect::highlighting::Style) -> termcolor::ColorSpec { 109 | use syntect::highlighting::FontStyle; 110 | let mut spec = termcolor::ColorSpec::new(); 111 | spec.set_fg(convert_color(style.foreground)) 112 | .set_underline(style.font_style.contains(FontStyle::UNDERLINE)) 113 | .set_bold(style.font_style.contains(FontStyle::BOLD)) 114 | .set_italic(style.font_style.contains(FontStyle::ITALIC)); 115 | spec 116 | } 117 | 118 | // https://github.com/sharkdp/bat/blob/3a85fd767bd1f03debd0a60ac5bc08548f95bc9d/src/terminal.rs 119 | fn convert_color(color: syntect::highlighting::Color) -> Option { 120 | use termcolor::Color; 121 | 122 | if color.a == 0 { 123 | // Themes can specify one of the user-configurable terminal colors by 124 | // encoding them as #RRGGBBAA with AA set to 00 (transparent) and RR set 125 | // to the 8-bit color palette number. The built-in themes ansi-light, 126 | // ansi-dark, base16, and base16-256 use this. 127 | match color.r { 128 | // For the first 7 colors, use the Color enum to produce ANSI escape 129 | // sequences using codes 30-37 (foreground) and 40-47 (background). 130 | // For example, red foreground is \x1b[31m. This works on terminals 131 | // without 256-color support. 132 | 0x00 => Some(Color::Black), 133 | 0x01 => Some(Color::Red), 134 | 0x02 => Some(Color::Green), 135 | 0x03 => Some(Color::Yellow), 136 | 0x04 => Some(Color::Blue), 137 | 0x05 => Some(Color::Magenta), 138 | 0x06 => Some(Color::Cyan), 139 | // The 8th color is white. Themes use it as the default foreground 140 | // color, but that looks wrong on terminals with a light background. 141 | // So keep that text uncolored instead. 142 | 0x07 => None, 143 | // For all other colors, produce escape sequences using 144 | // codes 38;5 (foreground) and 48;5 (background). For example, 145 | // bright red foreground is \x1b[38;5;9m. This only works on 146 | // terminals with 256-color support. 147 | n => Some(Color::Ansi256(n)), 148 | } 149 | } else { 150 | Some(Color::Rgb(color.r, color.g, color.b)) 151 | } 152 | } 153 | 154 | pub(crate) fn supports_hyperlinks() -> bool { 155 | static SUPPORTS_HYPERLINKS: OnceLock = OnceLock::new(); 156 | *SUPPORTS_HYPERLINKS.get_or_init(supports_hyperlinks::supports_hyperlinks) 157 | } 158 | 159 | pub(crate) fn create_hyperlink(text: &str, url: &str) -> String { 160 | // https://gist.github.com/egmontkob/eb114294efbcd5adb1944c9f3cb5feda 161 | format!("\x1B]8;;{url}\x1B\\{text}\x1B]8;;\x1B\\") 162 | } 163 | -------------------------------------------------------------------------------- /src/formatting/palette.rs: -------------------------------------------------------------------------------- 1 | //! We used to use syntect for all of our coloring and we still use syntect-compatible 2 | //! files to store themes. 3 | //! 4 | //! But we've started coloring some things manually for better control (and potentially 5 | //! for better efficiency). This macro loads colors from themes and exposes them as 6 | //! fields on a struct. See [`super::headers`] for an example. 7 | 8 | macro_rules! palette { 9 | { 10 | $vis:vis struct $name:ident { 11 | $($color:ident: $scopes:expr,)* 12 | } 13 | } => { 14 | $vis struct $name { 15 | $(pub $color: ::termcolor::ColorSpec,)* 16 | #[allow(unused)] 17 | pub default: ::termcolor::ColorSpec, 18 | } 19 | 20 | impl From<&::syntect::highlighting::Theme> for $name { 21 | fn from(theme: &::syntect::highlighting::Theme) -> Self { 22 | let highlighter = ::syntect::highlighting::Highlighter::new(theme); 23 | let mut parsed_scopes = ::std::vec::Vec::new(); 24 | Self { 25 | $($color: $crate::formatting::palette::util::extract_color( 26 | &highlighter, 27 | &$scopes, 28 | &mut parsed_scopes, 29 | ),)* 30 | default: $crate::formatting::palette::util::extract_default(theme), 31 | } 32 | } 33 | } 34 | } 35 | } 36 | 37 | pub(crate) use palette; 38 | 39 | pub(crate) mod util { 40 | use syntect::{ 41 | highlighting::{Highlighter, Theme}, 42 | parsing::Scope, 43 | }; 44 | use termcolor::ColorSpec; 45 | 46 | use crate::formatting::{convert_color, convert_style}; 47 | 48 | #[inline(never)] 49 | pub(crate) fn extract_color( 50 | highlighter: &Highlighter, 51 | scopes: &[&str], 52 | parsebuf: &mut Vec, 53 | ) -> ColorSpec { 54 | parsebuf.clear(); 55 | parsebuf.extend(scopes.iter().map(|s| s.parse::().unwrap())); 56 | let style = highlighter.style_for_stack(parsebuf); 57 | convert_style(style) 58 | } 59 | 60 | #[inline(never)] 61 | pub(crate) fn extract_default(theme: &Theme) -> ColorSpec { 62 | let mut color = ColorSpec::new(); 63 | if let Some(foreground) = theme.settings.foreground { 64 | color.set_fg(convert_color(foreground)); 65 | } 66 | color 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /src/generation.rs: -------------------------------------------------------------------------------- 1 | use std::io; 2 | 3 | use clap_complete::Shell; 4 | use clap_complete_nushell::Nushell; 5 | 6 | use crate::cli::Cli; 7 | use crate::cli::Generate; 8 | 9 | const MAN_TEMPLATE: &str = include_str!("../doc/man-template.roff"); 10 | 11 | pub fn generate(bin_name: &str, generate: Generate) { 12 | let mut app = Cli::into_app(); 13 | 14 | match generate { 15 | Generate::CompleteBash => { 16 | clap_complete::generate(Shell::Bash, &mut app, bin_name, &mut io::stdout()); 17 | } 18 | Generate::CompleteElvish => { 19 | clap_complete::generate(Shell::Elvish, &mut app, bin_name, &mut io::stdout()); 20 | } 21 | Generate::CompleteFish => { 22 | use std::io::Write; 23 | let mut buf = Vec::new(); 24 | clap_complete::generate(Shell::Fish, &mut app, bin_name, &mut buf); 25 | let mut stdout = io::stdout(); 26 | // Based on https://github.com/fish-shell/fish-shell/blob/1e61e6492db879ba6c32013f901d84b067ca22eb/share/completions/curl.fish#L1-L6 27 | let preamble = format!( 28 | r#"# Complete paths after @ in options: 29 | function __{bin_name}_complete_data 30 | string match -qr '^(?.*@)(?.*)' -- (commandline -ct) 31 | printf '%s\n' -- $prefix(__fish_complete_path $path) 32 | end 33 | complete -c {bin_name} -n 'string match -qr "@" -- (commandline -ct)' -kxa "(__{bin_name}_complete_data)" 34 | 35 | "#, 36 | bin_name = bin_name, 37 | ); 38 | stdout.write_all(preamble.as_bytes()).unwrap(); 39 | stdout.write_all(&buf).unwrap(); 40 | } 41 | Generate::CompleteNushell => { 42 | clap_complete::generate(Nushell, &mut app, bin_name, &mut io::stdout()); 43 | } 44 | Generate::CompletePowershell => { 45 | clap_complete::generate(Shell::PowerShell, &mut app, bin_name, &mut io::stdout()); 46 | } 47 | Generate::CompleteZsh => { 48 | clap_complete::generate(Shell::Zsh, &mut app, bin_name, &mut io::stdout()); 49 | } 50 | Generate::Man => { 51 | generate_manpages(&mut app); 52 | } 53 | } 54 | } 55 | 56 | fn generate_manpages(app: &mut clap::Command) { 57 | use roff::{bold, italic, roman, Roff}; 58 | use time::OffsetDateTime as DateTime; 59 | 60 | let items: Vec<_> = app.get_arguments().filter(|i| !i.is_hide_set()).collect(); 61 | 62 | let mut request_items_roff = Roff::new(); 63 | let request_items = items 64 | .iter() 65 | .find(|opt| opt.get_id() == "raw_rest_args") 66 | .unwrap(); 67 | let request_items_help = request_items 68 | .get_long_help() 69 | .or_else(|| request_items.get_help()) 70 | .expect("request_items is missing help") 71 | .to_string(); 72 | 73 | // replace the indents in request_item help with proper roff controls 74 | // For example: 75 | // 76 | // ``` 77 | // normal help normal help 78 | // normal help normal help 79 | // 80 | // request-item-1 81 | // help help 82 | // 83 | // request-item-2 84 | // help help 85 | // 86 | // normal help normal help 87 | // ``` 88 | // 89 | // Should look like this with roff controls 90 | // 91 | // ``` 92 | // normal help normal help 93 | // normal help normal help 94 | // .RS 12 95 | // .TP 96 | // request-item-1 97 | // help help 98 | // .TP 99 | // request-item-2 100 | // help help 101 | // .RE 102 | // 103 | // .RS 104 | // normal help normal help 105 | // .RE 106 | // ``` 107 | let lines: Vec<&str> = request_items_help.lines().collect(); 108 | let mut rs = false; 109 | for i in 0..lines.len() { 110 | if lines[i].is_empty() { 111 | let prev = lines[i - 1].chars().take_while(|&x| x == ' ').count(); 112 | let next = lines[i + 1].chars().take_while(|&x| x == ' ').count(); 113 | if prev != next && next > 0 { 114 | if !rs { 115 | request_items_roff.control("RS", ["8"]); 116 | rs = true; 117 | } 118 | request_items_roff.control("TP", ["4"]); 119 | } else if prev != next && next == 0 { 120 | request_items_roff.control("RE", []); 121 | request_items_roff.text(vec![roman("")]); 122 | request_items_roff.control("RS", []); 123 | } else { 124 | request_items_roff.text(vec![roman(lines[i])]); 125 | } 126 | } else { 127 | request_items_roff.text(vec![roman(lines[i].trim())]); 128 | } 129 | } 130 | request_items_roff.control("RE", []); 131 | 132 | let mut options_roff = Roff::new(); 133 | let non_pos_items = items 134 | .iter() 135 | .filter(|a| !a.is_positional()) 136 | .collect::>(); 137 | 138 | for opt in non_pos_items { 139 | let mut header = vec![]; 140 | if let Some(short) = opt.get_short() { 141 | header.push(bold(format!("-{}", short))); 142 | } 143 | if let Some(long) = opt.get_long() { 144 | if !header.is_empty() { 145 | header.push(roman(", ")); 146 | } 147 | header.push(bold(format!("--{}", long))); 148 | } 149 | if opt.get_action().takes_values() { 150 | let value_name = &opt.get_value_names().unwrap(); 151 | if opt.get_long().is_some() { 152 | header.push(roman("=")); 153 | } else { 154 | header.push(roman(" ")); 155 | } 156 | 157 | if opt.get_id() == "auth" { 158 | header.push(italic("USER")); 159 | header.push(roman("[")); 160 | header.push(italic(":PASS")); 161 | header.push(roman("] | ")); 162 | header.push(italic("TOKEN")); 163 | } else { 164 | header.push(italic(value_name.join(" "))); 165 | } 166 | } 167 | let mut body = vec![]; 168 | 169 | let mut help = opt 170 | .get_long_help() 171 | .or_else(|| opt.get_help()) 172 | .expect("option is missing help") 173 | .to_string(); 174 | if !help.ends_with('.') { 175 | help.push('.') 176 | } 177 | body.push(roman(help)); 178 | 179 | let possible_values = opt.get_possible_values(); 180 | if !possible_values.is_empty() 181 | && !opt.is_hide_possible_values_set() 182 | && opt.get_id() != "pretty" 183 | { 184 | let possible_values_text = format!( 185 | "\n\n[possible values: {}]", 186 | possible_values 187 | .iter() 188 | .map(|v| v.get_name()) 189 | .collect::>() 190 | .join(", ") 191 | ); 192 | body.push(roman(possible_values_text)); 193 | } 194 | options_roff.control("TP", ["4"]); 195 | options_roff.text(header); 196 | options_roff.text(body); 197 | } 198 | 199 | let mut manpage = MAN_TEMPLATE.to_string(); 200 | 201 | let current_date = { 202 | // https://reproducible-builds.org/docs/source-date-epoch/ 203 | let now = match std::env::var("SOURCE_DATE_EPOCH") { 204 | Ok(val) => DateTime::from_unix_timestamp(val.parse::().unwrap()).unwrap(), 205 | Err(_) => DateTime::now_utc(), 206 | }; 207 | let (year, month, day) = now.date().to_calendar_date(); 208 | format!("{}-{:02}-{:02}", year, u8::from(month), day) 209 | }; 210 | 211 | manpage = manpage.replace("{{date}}", ¤t_date); 212 | manpage = manpage.replace("{{version}}", app.get_version().unwrap()); 213 | manpage = manpage.replace("{{request_items}}", request_items_roff.to_roff().trim()); 214 | manpage = manpage.replace("{{options}}", options_roff.to_roff().trim()); 215 | 216 | print!("{manpage}"); 217 | } 218 | -------------------------------------------------------------------------------- /src/middleware.rs: -------------------------------------------------------------------------------- 1 | use std::time::{Duration, Instant}; 2 | 3 | use anyhow::Result; 4 | use reqwest::blocking::{Client, Request, Response}; 5 | 6 | #[derive(Clone)] 7 | pub struct ResponseMeta { 8 | pub request_duration: Duration, 9 | pub content_download_duration: Option, 10 | } 11 | 12 | pub trait ResponseExt { 13 | fn meta(&self) -> &ResponseMeta; 14 | fn meta_mut(&mut self) -> &mut ResponseMeta; 15 | } 16 | 17 | impl ResponseExt for Response { 18 | fn meta(&self) -> &ResponseMeta { 19 | self.extensions().get::().unwrap() 20 | } 21 | 22 | fn meta_mut(&mut self) -> &mut ResponseMeta { 23 | self.extensions_mut().get_mut::().unwrap() 24 | } 25 | } 26 | 27 | type Printer<'a, 'b> = &'a mut (dyn FnMut(&mut Response, &mut Request) -> Result<()> + 'b); 28 | 29 | pub struct Context<'a, 'b> { 30 | client: &'a Client, 31 | printer: Option>, 32 | middlewares: &'a mut [Box], 33 | } 34 | 35 | impl<'a, 'b> Context<'a, 'b> { 36 | fn new( 37 | client: &'a Client, 38 | printer: Option>, 39 | middlewares: &'a mut [Box], 40 | ) -> Self { 41 | Context { 42 | client, 43 | printer, 44 | middlewares, 45 | } 46 | } 47 | 48 | fn execute(&mut self, request: Request) -> Result { 49 | match self.middlewares { 50 | [] => { 51 | let starting_time = Instant::now(); 52 | let mut response = self.client.execute(request)?; 53 | response.extensions_mut().insert(ResponseMeta { 54 | request_duration: starting_time.elapsed(), 55 | content_download_duration: None, 56 | }); 57 | Ok(response) 58 | } 59 | [ref mut head, tail @ ..] => head.handle( 60 | #[allow(clippy::needless_option_as_deref)] 61 | Context::new(self.client, self.printer.as_deref_mut(), tail), 62 | request, 63 | ), 64 | } 65 | } 66 | } 67 | 68 | pub trait Middleware { 69 | fn handle(&mut self, ctx: Context, request: Request) -> Result; 70 | 71 | fn next(&self, ctx: &mut Context, request: Request) -> Result { 72 | ctx.execute(request) 73 | } 74 | 75 | fn print( 76 | &self, 77 | ctx: &mut Context, 78 | response: &mut Response, 79 | request: &mut Request, 80 | ) -> Result<()> { 81 | if let Some(ref mut printer) = ctx.printer { 82 | printer(response, request)?; 83 | } 84 | 85 | Ok(()) 86 | } 87 | } 88 | 89 | pub struct ClientWithMiddleware<'a, T> 90 | where 91 | T: FnMut(&mut Response, &mut Request) -> Result<()>, 92 | { 93 | client: &'a Client, 94 | printer: Option, 95 | middlewares: Vec>, 96 | } 97 | 98 | impl<'a, T> ClientWithMiddleware<'a, T> 99 | where 100 | T: FnMut(&mut Response, &mut Request) -> Result<()> + 'a, 101 | { 102 | pub fn new(client: &'a Client) -> Self { 103 | ClientWithMiddleware { 104 | client, 105 | printer: None, 106 | middlewares: vec![], 107 | } 108 | } 109 | 110 | pub fn with_printer(mut self, printer: T) -> Self { 111 | self.printer = Some(printer); 112 | self 113 | } 114 | 115 | pub fn with(mut self, middleware: impl Middleware + 'a) -> Self { 116 | self.middlewares.push(Box::new(middleware)); 117 | self 118 | } 119 | 120 | pub fn execute(&mut self, request: Request) -> Result { 121 | let mut ctx = Context::new( 122 | self.client, 123 | self.printer.as_mut().map(|p| p as _), 124 | &mut self.middlewares[..], 125 | ); 126 | ctx.execute(request) 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /src/redacted.rs: -------------------------------------------------------------------------------- 1 | use std::ffi::OsString; 2 | use std::fmt::{self, Debug}; 3 | use std::ops::Deref; 4 | use std::str::FromStr; 5 | 6 | /// A String that doesn't show up in Debug representations. 7 | /// 8 | /// This is important for logging, where we maybe want to avoid outputting 9 | /// sensitive data. 10 | #[derive(Clone, PartialEq, Eq)] 11 | pub struct SecretString(String); 12 | 13 | impl FromStr for SecretString { 14 | type Err = std::convert::Infallible; 15 | 16 | fn from_str(s: &str) -> Result { 17 | Ok(Self(s.to_owned())) 18 | } 19 | } 20 | 21 | impl Deref for SecretString { 22 | type Target = String; 23 | 24 | fn deref(&self) -> &String { 25 | &self.0 26 | } 27 | } 28 | 29 | impl Debug for SecretString { 30 | fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { 31 | // Uncomment this to see the string anyway: 32 | // self.0.fmt(f); 33 | // If that turns out to be frequently necessary we could 34 | // make this configurable at runtime, e.g. by flipping an 35 | // AtomicBool depending on an environment variable. 36 | f.write_str("(redacted)") 37 | } 38 | } 39 | 40 | impl From for OsString { 41 | fn from(string: SecretString) -> OsString { 42 | string.0.into() 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/redirect.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Result; 2 | use reqwest::blocking::{Request, Response}; 3 | use reqwest::header::{ 4 | HeaderMap, AUTHORIZATION, CONTENT_ENCODING, CONTENT_LENGTH, CONTENT_TYPE, COOKIE, LOCATION, 5 | PROXY_AUTHORIZATION, TRANSFER_ENCODING, WWW_AUTHENTICATE, 6 | }; 7 | use reqwest::{Method, StatusCode, Url}; 8 | 9 | use crate::middleware::{Context, Middleware}; 10 | use crate::utils::{clone_request, HeaderValueExt}; 11 | 12 | pub struct RedirectFollower { 13 | max_redirects: usize, 14 | } 15 | 16 | impl RedirectFollower { 17 | pub fn new(max_redirects: usize) -> Self { 18 | RedirectFollower { max_redirects } 19 | } 20 | } 21 | 22 | impl Middleware for RedirectFollower { 23 | fn handle(&mut self, mut ctx: Context, mut first_request: Request) -> Result { 24 | // This buffers the body in case we need it again later 25 | // reqwest does *not* do this, it ignores 307/308 with a streaming body 26 | let mut request = clone_request(&mut first_request)?; 27 | let mut response = self.next(&mut ctx, first_request)?; 28 | let mut remaining_redirects = self.max_redirects - 1; 29 | 30 | while let Some(mut next_request) = get_next_request(request, &response) { 31 | if remaining_redirects > 0 { 32 | remaining_redirects -= 1; 33 | } else { 34 | return Err(TooManyRedirects { 35 | max_redirects: self.max_redirects, 36 | } 37 | .into()); 38 | } 39 | log::info!("Following redirect to {}", next_request.url()); 40 | log::trace!("Remaining redirects: {}", remaining_redirects); 41 | log::trace!("{next_request:#?}"); 42 | self.print(&mut ctx, &mut response, &mut next_request)?; 43 | request = clone_request(&mut next_request)?; 44 | response = self.next(&mut ctx, next_request)?; 45 | } 46 | 47 | Ok(response) 48 | } 49 | } 50 | 51 | #[derive(Debug)] 52 | pub(crate) struct TooManyRedirects { 53 | max_redirects: usize, 54 | } 55 | 56 | impl std::fmt::Display for TooManyRedirects { 57 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 58 | write!( 59 | f, 60 | "Too many redirects (--max-redirects={})", 61 | self.max_redirects, 62 | ) 63 | } 64 | } 65 | 66 | impl std::error::Error for TooManyRedirects {} 67 | 68 | // See https://github.com/seanmonstar/reqwest/blob/bbeb1ede4e8098481c3de6f2cafb8ecca1db4ede/src/async_impl/client.rs#L1500-L1607 69 | fn get_next_request(mut request: Request, response: &Response) -> Option { 70 | let get_next_url = |request: &Request| { 71 | let location = response.headers().get(LOCATION)?; 72 | let url = location 73 | .to_utf8_str() 74 | .ok() 75 | .and_then(|location| request.url().join(location).ok()); 76 | if url.is_none() { 77 | log::warn!("Redirect to invalid URL: {location:?}"); 78 | } 79 | url 80 | }; 81 | 82 | match response.status() { 83 | StatusCode::MOVED_PERMANENTLY | StatusCode::FOUND | StatusCode::SEE_OTHER => { 84 | let next_url = get_next_url(&request)?; 85 | log::trace!("Preparing redirect to {next_url}"); 86 | let prev_url = request.url(); 87 | if is_cross_domain_redirect(&next_url, prev_url) { 88 | remove_sensitive_headers(request.headers_mut()); 89 | } 90 | remove_content_headers(request.headers_mut()); 91 | *request.url_mut() = next_url; 92 | *request.body_mut() = None; 93 | *request.method_mut() = match *request.method() { 94 | Method::GET => Method::GET, 95 | Method::HEAD => Method::HEAD, 96 | _ => Method::GET, 97 | }; 98 | Some(request) 99 | } 100 | StatusCode::TEMPORARY_REDIRECT | StatusCode::PERMANENT_REDIRECT => { 101 | let next_url = get_next_url(&request)?; 102 | log::trace!("Preparing redirect to {next_url}"); 103 | let prev_url = request.url(); 104 | if is_cross_domain_redirect(&next_url, prev_url) { 105 | remove_sensitive_headers(request.headers_mut()); 106 | } 107 | *request.url_mut() = next_url; 108 | Some(request) 109 | } 110 | _ => None, 111 | } 112 | } 113 | 114 | // See https://github.com/seanmonstar/reqwest/blob/bbeb1ede4e8098481c3de6f2cafb8ecca1db4ede/src/redirect.rs#L234-L246 115 | fn is_cross_domain_redirect(next: &Url, previous: &Url) -> bool { 116 | next.host_str() != previous.host_str() 117 | || next.port_or_known_default() != previous.port_or_known_default() 118 | } 119 | 120 | // See https://github.com/seanmonstar/reqwest/blob/bbeb1ede4e8098481c3de6f2cafb8ecca1db4ede/src/redirect.rs#L234-L246 121 | fn remove_sensitive_headers(headers: &mut HeaderMap) { 122 | log::debug!("Removing sensitive headers for cross-domain redirect"); 123 | headers.remove(AUTHORIZATION); 124 | headers.remove(COOKIE); 125 | headers.remove("cookie2"); 126 | headers.remove(PROXY_AUTHORIZATION); 127 | headers.remove(WWW_AUTHENTICATE); 128 | } 129 | 130 | // See https://github.com/seanmonstar/reqwest/blob/bbeb1ede4e8098481c3de6f2cafb8ecca1db4ede/src/async_impl/client.rs#L1503-L1510 131 | fn remove_content_headers(headers: &mut HeaderMap) { 132 | log::debug!("Removing content headers for redirect that strips body"); 133 | headers.remove(TRANSFER_ENCODING); 134 | headers.remove(CONTENT_ENCODING); 135 | headers.remove(CONTENT_TYPE); 136 | headers.remove(CONTENT_LENGTH); 137 | } 138 | -------------------------------------------------------------------------------- /src/utils.rs: -------------------------------------------------------------------------------- 1 | use std::borrow::Cow; 2 | use std::env::var_os; 3 | use std::io::{self, Write}; 4 | use std::path::{Path, PathBuf}; 5 | use std::str::Utf8Error; 6 | 7 | use anyhow::Result; 8 | use reqwest::blocking::{Request, Response}; 9 | use reqwest::header::HeaderValue; 10 | use url::Url; 11 | 12 | pub fn unescape(text: &str, special_chars: &'static str) -> String { 13 | let mut out = String::new(); 14 | let mut chars = text.chars(); 15 | while let Some(ch) = chars.next() { 16 | if ch == '\\' { 17 | match chars.next() { 18 | Some(next) if special_chars.contains(next) => { 19 | // Escape this character 20 | out.push(next); 21 | } 22 | Some(next) => { 23 | // Do not escape this character, treat backslash 24 | // as ordinary character 25 | out.push(ch); 26 | out.push(next); 27 | } 28 | None => { 29 | out.push(ch); 30 | } 31 | } 32 | } else { 33 | out.push(ch); 34 | } 35 | } 36 | out 37 | } 38 | 39 | pub fn clone_request(request: &mut Request) -> Result { 40 | if let Some(b) = request.body_mut().as_mut() { 41 | b.buffer()?; 42 | } 43 | // This doesn't copy the contents of the buffer, cloning requests is cheap 44 | // https://docs.rs/bytes/1.0.1/bytes/struct.Bytes.html 45 | Ok(request.try_clone().unwrap()) // guaranteed to not fail if body is already buffered 46 | } 47 | 48 | /// Whether to make some things more deterministic for the benefit of tests 49 | pub fn test_mode() -> bool { 50 | // In integration tests the binary isn't compiled with cfg(test), so we 51 | // use an environment variable. 52 | // This isn't called very often currently but we could cache it using an 53 | // atomic integer. 54 | cfg!(test) || var_os("XH_TEST_MODE").is_some() 55 | } 56 | 57 | /// Whether to behave as if stdin and stdout are terminals 58 | pub fn test_pretend_term() -> bool { 59 | var_os("XH_TEST_MODE_TERM").is_some() 60 | } 61 | 62 | pub fn test_default_color() -> bool { 63 | var_os("XH_TEST_MODE_COLOR").is_some() 64 | } 65 | 66 | #[cfg(test)] 67 | pub fn random_string() -> String { 68 | use rand::Rng; 69 | 70 | rand::thread_rng() 71 | .sample_iter(&rand::distributions::Alphanumeric) 72 | .take(10) 73 | .map(char::from) 74 | .collect() 75 | } 76 | 77 | pub fn config_dir() -> Option { 78 | if let Some(dir) = std::env::var_os("XH_CONFIG_DIR") { 79 | return Some(dir.into()); 80 | } 81 | 82 | if cfg!(target_os = "macos") { 83 | // On macOS dirs returns `~/Library/Application Support`. 84 | // ~/.config is more usual so we switched to that. But first we check for 85 | // the legacy location. 86 | let legacy_config_dir = dirs::config_dir()?.join("xh"); 87 | let config_home = match var_os("XDG_CONFIG_HOME") { 88 | Some(dir) => dir.into(), 89 | None => dirs::home_dir()?.join(".config"), 90 | }; 91 | let new_config_dir = config_home.join("xh"); 92 | if legacy_config_dir.exists() && !new_config_dir.exists() { 93 | Some(legacy_config_dir) 94 | } else { 95 | Some(new_config_dir) 96 | } 97 | } else { 98 | Some(dirs::config_dir()?.join("xh")) 99 | } 100 | } 101 | 102 | pub fn get_home_dir() -> Option { 103 | #[cfg(target_os = "windows")] 104 | if let Some(path) = std::env::var_os("XH_TEST_MODE_WIN_HOME_DIR") { 105 | return Some(PathBuf::from(path)); 106 | } 107 | 108 | dirs::home_dir() 109 | } 110 | 111 | /// Perform simple tilde expansion if `dirs::home_dir()` is `Some(path)`. 112 | /// 113 | /// Note that prefixed tilde e.g `~foo` is ignored. 114 | /// 115 | /// See https://www.gnu.org/software/bash/manual/html_node/Tilde-Expansion.html 116 | pub fn expand_tilde(path: impl AsRef) -> PathBuf { 117 | if let Ok(path) = path.as_ref().strip_prefix("~") { 118 | let mut expanded_path = PathBuf::new(); 119 | expanded_path.push(get_home_dir().unwrap_or_else(|| "~".into())); 120 | expanded_path.push(path); 121 | expanded_path 122 | } else { 123 | path.as_ref().into() 124 | } 125 | } 126 | 127 | pub fn url_with_query(mut url: Url, query: &[(&str, Cow)]) -> Url { 128 | if !query.is_empty() { 129 | // If we run this even without adding pairs it adds a `?`, hence 130 | // the .is_empty() check 131 | let mut pairs = url.query_pairs_mut(); 132 | for (name, value) in query { 133 | pairs.append_pair(name, value); 134 | } 135 | } 136 | url 137 | } 138 | 139 | // https://stackoverflow.com/a/45145246/5915221 140 | #[macro_export] 141 | macro_rules! vec_of_strings { 142 | ($($str:expr),*) => ({ 143 | vec![$(String::from($str),)*] as Vec 144 | }); 145 | } 146 | 147 | /// When downloading a large file from a local nginx, it seems that 128KiB 148 | /// is a bit faster than 64KiB but bumping it up to 256KiB doesn't help any 149 | /// more. 150 | /// When increasing the buffer size all the way to 1MiB I observe 408KiB as 151 | /// the largest read size. But this doesn't translate to a shorter runtime. 152 | pub const BUFFER_SIZE: usize = 128 * 1024; 153 | 154 | /// io::copy, but with a larger buffer size. 155 | /// 156 | /// io::copy's buffer is just 8 KiB. This noticeably slows down fast 157 | /// large downloads, especially with a progress bar. 158 | /// 159 | /// If `flush` is true, the writer will be flushed after each write. This is 160 | /// appropriate for streaming output, where you don't want a delay between data 161 | /// arriving and being shown. 162 | pub fn copy_largebuf( 163 | reader: &mut impl io::Read, 164 | writer: &mut impl Write, 165 | flush: bool, 166 | ) -> io::Result<()> { 167 | let mut buf = vec![0; BUFFER_SIZE]; 168 | loop { 169 | match reader.read(&mut buf) { 170 | Ok(0) => return Ok(()), 171 | Ok(len) => { 172 | writer.write_all(&buf[..len])?; 173 | if flush { 174 | writer.flush()?; 175 | } 176 | } 177 | Err(ref e) if e.kind() == io::ErrorKind::Interrupted => continue, 178 | Err(e) => return Err(e), 179 | } 180 | } 181 | } 182 | 183 | pub(crate) trait HeaderValueExt { 184 | fn to_utf8_str(&self) -> Result<&str, Utf8Error>; 185 | 186 | fn to_ascii_or_latin1(&self) -> Result<&str, BadHeaderValue<'_>>; 187 | } 188 | 189 | impl HeaderValueExt for HeaderValue { 190 | fn to_utf8_str(&self) -> Result<&str, Utf8Error> { 191 | std::str::from_utf8(self.as_bytes()) 192 | } 193 | 194 | /// If the value is pure ASCII, return Ok(). If not, return Err() with methods for 195 | /// further handling. 196 | /// 197 | /// The Ok() version cannot contain control characters (not even ASCII ones). 198 | fn to_ascii_or_latin1(&self) -> Result<&str, BadHeaderValue<'_>> { 199 | self.to_str().map_err(|_| BadHeaderValue { value: self }) 200 | } 201 | } 202 | 203 | #[derive(Clone, Copy, Debug, PartialEq, Eq)] 204 | pub(crate) struct BadHeaderValue<'a> { 205 | value: &'a HeaderValue, 206 | } 207 | 208 | impl<'a> BadHeaderValue<'a> { 209 | /// Return the header value's latin1 decoding, AKA isomorphic decode, 210 | /// AKA ISO-8859-1 decode. This is how browsers tend to handle it. 211 | /// 212 | /// Not to be confused with ISO 8859-1 (which leaves 0x8X and 0x9X unmapped) 213 | /// or with Windows-1252 (which is how HTTP bodies are decoded if they 214 | /// declare `Content-Encoding: iso-8859-1`). 215 | /// 216 | /// Is likely to contain control characters. Consider replacing these. 217 | pub(crate) fn latin1(self) -> String { 218 | // https://infra.spec.whatwg.org/#isomorphic-decode 219 | self.value.as_bytes().iter().map(|&b| b as char).collect() 220 | } 221 | 222 | /// Return the header value's UTF-8 decoding. This is most likely what the 223 | /// user expects, but when browsers prefer another encoding we should give 224 | /// that one precedence. 225 | pub(crate) fn utf8(self) -> Option<&'a str> { 226 | self.value.to_utf8_str().ok() 227 | } 228 | } 229 | 230 | pub(crate) fn reason_phrase(response: &Response) -> Cow<'_, str> { 231 | if let Some(reason) = response.extensions().get::() { 232 | // The server sent a non-standard reason phrase. 233 | // Seems like some browsers interpret this as latin1 and others as UTF-8? 234 | // Rare case and clients aren't supposed to pay attention to the reason 235 | // phrase so let's just do UTF-8 for convenience. 236 | // We could send the bytes straight to stdout/stderr in case they're some 237 | // other encoding but that's probably not worth the effort. 238 | String::from_utf8_lossy(reason.as_bytes()) 239 | } else if let Some(reason) = response.status().canonical_reason() { 240 | // On HTTP/2+ no reason phrase is sent so we're just explaining the code 241 | // to the user. 242 | // On HTTP/1.1 and below this matches the reason the server actually sent 243 | // or else hyper would have added a ReasonPhrase. 244 | Cow::Borrowed(reason) 245 | } else { 246 | // Only reachable in case of an unknown status code over HTTP/2+. 247 | // curl prints nothing in this case. 248 | Cow::Borrowed("") 249 | } 250 | } 251 | 252 | #[cfg(test)] 253 | mod tests { 254 | use super::*; 255 | 256 | #[test] 257 | fn test_latin1() { 258 | let good = HeaderValue::from_static("Rhodes"); 259 | let good = good.to_ascii_or_latin1(); 260 | 261 | assert_eq!(good, Ok("Rhodes")); 262 | 263 | let bad = HeaderValue::from_bytes("Ῥόδος".as_bytes()).unwrap(); 264 | let bad = bad.to_ascii_or_latin1().unwrap_err(); 265 | 266 | assert_eq!(bad.latin1(), "ῬÏ\u{8c}δοÏ\u{82}"); 267 | assert_eq!(bad.utf8(), Some("Ῥόδος")); 268 | 269 | let worse = HeaderValue::from_bytes(b"R\xF3dos").unwrap(); 270 | let worse = worse.to_ascii_or_latin1().unwrap_err(); 271 | 272 | assert_eq!(worse.latin1(), "Ródos"); 273 | assert_eq!(worse.utf8(), None); 274 | } 275 | } 276 | -------------------------------------------------------------------------------- /tests/cases/compress_request_body.rs: -------------------------------------------------------------------------------- 1 | use std::{fs::OpenOptions, io::Read as _}; 2 | 3 | use hyper::header::HeaderValue; 4 | use predicates::str::contains; 5 | 6 | use crate::prelude::*; 7 | use std::io::Write; 8 | 9 | fn zlib_decode(bytes: Vec) -> std::io::Result { 10 | let mut z = flate2::read::ZlibDecoder::new(&bytes[..]); 11 | let mut s = String::new(); 12 | z.read_to_string(&mut s)?; 13 | Ok(s) 14 | } 15 | 16 | fn server() -> server::Server { 17 | server::http(|req| async move { 18 | match req.uri().path() { 19 | "/deflate" => { 20 | assert_eq!( 21 | req.headers().get(hyper::header::CONTENT_ENCODING), 22 | Some(HeaderValue::from_static("deflate")).as_ref() 23 | ); 24 | 25 | let compressed_body = req.body().await; 26 | let body = zlib_decode(compressed_body).unwrap(); 27 | hyper::Response::builder() 28 | .header("date", "N/A") 29 | .header("Content-Type", "text/plain") 30 | .body(body.into()) 31 | .unwrap() 32 | } 33 | "/normal" => { 34 | assert_eq!(req.headers().get(hyper::header::CONTENT_ENCODING), None); 35 | 36 | let body = req.body_as_string().await; 37 | hyper::Response::builder() 38 | .header("date", "N/A") 39 | .header("Content-Type", "text/plain") 40 | .body(body.into()) 41 | .unwrap() 42 | } 43 | _ => panic!("unknown path"), 44 | } 45 | }) 46 | } 47 | 48 | #[test] 49 | fn compress_request_body_json() { 50 | let server = server(); 51 | 52 | get_command() 53 | .arg(format!("{}/deflate", server.base_url())) 54 | .args([ 55 | &format!("key={}", "1".repeat(1000)), 56 | "-x", 57 | "-j", 58 | "--pretty=none", 59 | ]) 60 | .assert() 61 | .stdout(indoc::formatdoc! {r#" 62 | HTTP/1.1 200 OK 63 | Date: N/A 64 | Content-Type: text/plain 65 | Content-Length: 1010 66 | 67 | {{"key":"{c}"}} 68 | "#, c = "1".repeat(1000),}); 69 | } 70 | 71 | #[test] 72 | fn compress_request_body_form() { 73 | let server = server(); 74 | 75 | get_command() 76 | .arg(format!("{}/deflate", server.base_url())) 77 | .args([ 78 | &format!("key={}", "1".repeat(1000)), 79 | "-x", 80 | "-x", 81 | "-f", 82 | "--pretty=none", 83 | ]) 84 | .assert() 85 | .stdout(indoc::formatdoc! {r#" 86 | HTTP/1.1 200 OK 87 | Date: N/A 88 | Content-Type: text/plain 89 | Content-Length: 1004 90 | 91 | key={c} 92 | "#, c = "1".repeat(1000),}); 93 | } 94 | 95 | #[test] 96 | fn skip_compression_when_compression_ratio_is_negative() { 97 | let server = server(); 98 | get_command() 99 | .arg(format!("{}/normal", server.base_url())) 100 | .args([&format!("key={}", "1"), "-x", "-f", "--pretty=none"]) 101 | .assert() 102 | .stdout(indoc::formatdoc! {r#" 103 | HTTP/1.1 200 OK 104 | Date: N/A 105 | Content-Type: text/plain 106 | Content-Length: 5 107 | 108 | key={c} 109 | "#, c = "1"}); 110 | } 111 | 112 | #[test] 113 | fn test_compress_force_with_negative_ratio() { 114 | let server = server(); 115 | get_command() 116 | .arg(format!("{}/deflate", server.base_url())) 117 | .args([&format!("key={}", "1"), "-xx", "-f", "--pretty=none"]) 118 | .assert() 119 | .stdout(indoc::formatdoc! {r#" 120 | HTTP/1.1 200 OK 121 | Date: N/A 122 | Content-Type: text/plain 123 | Content-Length: 5 124 | 125 | key={c} 126 | "#, c = "1"}); 127 | } 128 | 129 | #[test] 130 | fn dont_compress_request_body_if_content_encoding_have_value() { 131 | let server = server::http(|req| async move { 132 | assert_eq!( 133 | req.headers().get(hyper::header::CONTENT_ENCODING), 134 | Some(HeaderValue::from_static("identity")).as_ref() 135 | ); 136 | 137 | let body = req.body_as_string().await; 138 | hyper::Response::builder() 139 | .header("date", "N/A") 140 | .header("Content-Type", "text/plain") 141 | .body(body.into()) 142 | .unwrap() 143 | }); 144 | get_command() 145 | .arg(format!("{}/", server.base_url())) 146 | .args([ 147 | &format!("key={}", "1".repeat(1000)), 148 | "content-encoding:identity", 149 | "-xx", 150 | "-f", 151 | "--pretty=none", 152 | ]) 153 | .assert() 154 | .stdout(indoc::formatdoc! {r#" 155 | HTTP/1.1 200 OK 156 | Date: N/A 157 | Content-Type: text/plain 158 | Content-Length: 1004 159 | 160 | key={c} 161 | "#, c = "1".repeat(1000),}) 162 | .stderr(contains( "warning: --compress can't be used with a 'Content-Encoding:' header. --compress will be disabled.")) 163 | .success() 164 | ; 165 | } 166 | 167 | #[test] 168 | fn compress_body_from_file() { 169 | let server = server::http(|req| async move { 170 | assert_eq!( 171 | req.headers().get(hyper::header::CONTENT_ENCODING), 172 | Some(HeaderValue::from_static("deflate")).as_ref() 173 | ); 174 | assert_eq!("Hello world\n", zlib_decode(req.body().await).unwrap()); 175 | hyper::Response::default() 176 | }); 177 | 178 | let dir = tempfile::tempdir().unwrap(); 179 | let filename = dir.path().join("input.txt"); 180 | OpenOptions::new() 181 | .create(true) 182 | .truncate(true) 183 | .write(true) 184 | .open(&filename) 185 | .unwrap() 186 | .write_all(b"Hello world\n") 187 | .unwrap(); 188 | 189 | get_command() 190 | .arg(server.base_url()) 191 | .arg("-xx") 192 | .arg(format!("@{}", filename.to_string_lossy())) 193 | .assert() 194 | .success(); 195 | } 196 | 197 | #[test] 198 | fn compress_body_from_file_unless_compress_rate_less_1() { 199 | let server = server::http(|req| async move { 200 | assert_eq!(req.headers().get(hyper::header::CONTENT_ENCODING), None); 201 | assert_eq!("Hello world\n", req.body_as_string().await); 202 | hyper::Response::default() 203 | }); 204 | 205 | let dir = tempfile::tempdir().unwrap(); 206 | let filename = dir.path().join("input.txt"); 207 | OpenOptions::new() 208 | .create(true) 209 | .truncate(true) 210 | .write(true) 211 | .open(&filename) 212 | .unwrap() 213 | .write_all(b"Hello world\n") 214 | .unwrap(); 215 | 216 | get_command() 217 | .arg(server.base_url()) 218 | .arg("-x") 219 | .arg(format!("@{}", filename.to_string_lossy())) 220 | .assert() 221 | .success(); 222 | } 223 | 224 | #[test] 225 | fn test_cannot_combine_compress_with_multipart() { 226 | get_command() 227 | .arg(format!("{}/deflate", "")) 228 | .args(["--multipart", "-x", "a=1"]) 229 | .assert() 230 | .failure() 231 | .stderr(contains( 232 | "the argument '--multipart' cannot be used with '--compress...'", 233 | )); 234 | } 235 | -------------------------------------------------------------------------------- /tests/cases/logging.rs: -------------------------------------------------------------------------------- 1 | use hyper::header::HeaderValue; 2 | use predicates::str::contains; 3 | 4 | use crate::prelude::*; 5 | 6 | #[test] 7 | fn logs_are_printed_in_debug_mode() { 8 | get_command() 9 | .arg("--debug") 10 | .arg("--offline") 11 | .arg(":") 12 | .env_remove("RUST_LOG") 13 | .assert() 14 | .stderr(contains("DEBUG xh] Cli {")) 15 | .success(); 16 | } 17 | 18 | #[test] 19 | fn logs_are_not_printed_outside_debug_mode() { 20 | get_command() 21 | .arg("--offline") 22 | .arg(":") 23 | .env_remove("RUST_LOG") 24 | .assert() 25 | .stderr("") 26 | .success(); 27 | } 28 | 29 | #[test] 30 | fn backtrace_is_printed_in_debug_mode() { 31 | let mut server = server::http(|_req| async move { 32 | panic!("test crash"); 33 | }); 34 | server.disable_hit_checks(); 35 | get_command() 36 | .arg("--debug") 37 | .arg(server.base_url()) 38 | .env_remove("RUST_BACKTRACE") 39 | .env_remove("RUST_LIB_BACKTRACE") 40 | .assert() 41 | .stderr(contains("Stack backtrace:")) 42 | .failure(); 43 | } 44 | 45 | #[test] 46 | fn backtrace_is_not_printed_outside_debug_mode() { 47 | let mut server = server::http(|_req| async move { 48 | panic!("test crash"); 49 | }); 50 | server.disable_hit_checks(); 51 | let cmd = get_command() 52 | .arg(server.base_url()) 53 | .env_remove("RUST_BACKTRACE") 54 | .env_remove("RUST_LIB_BACKTRACE") 55 | .assert() 56 | .failure(); 57 | assert!(!std::str::from_utf8(&cmd.get_output().stderr) 58 | .unwrap() 59 | .contains("Stack backtrace:")); 60 | } 61 | 62 | #[test] 63 | fn checked_status_is_printed_with_single_quiet() { 64 | let server = server::http(|_req| async move { 65 | hyper::Response::builder() 66 | .status(404) 67 | .body("".into()) 68 | .unwrap() 69 | }); 70 | 71 | get_command() 72 | .args(["--quiet", "--check-status", &server.base_url()]) 73 | .assert() 74 | .code(4) 75 | .stdout("") 76 | .stderr("xh: warning: HTTP 404 Not Found\n"); 77 | } 78 | 79 | #[test] 80 | fn checked_status_is_not_printed_with_double_quiet() { 81 | let server = server::http(|_req| async move { 82 | hyper::Response::builder() 83 | .status(404) 84 | .body("".into()) 85 | .unwrap() 86 | }); 87 | 88 | get_command() 89 | .args(["--quiet", "--quiet", "--check-status", &server.base_url()]) 90 | .assert() 91 | .code(4) 92 | .stdout("") 93 | .stderr(""); 94 | } 95 | 96 | #[test] 97 | fn warning_for_invalid_redirect() { 98 | let server = server::http(|_req| async move { 99 | hyper::Response::builder() 100 | .status(302) 101 | .header("location", "//") 102 | .body("".into()) 103 | .unwrap() 104 | }); 105 | 106 | get_command() 107 | .args(["--follow", &server.base_url()]) 108 | .assert() 109 | .stderr("xh: warning: Redirect to invalid URL: \"//\"\n"); 110 | } 111 | 112 | #[test] 113 | fn warning_for_non_utf8_redirect() { 114 | let server = server::http(|_req| async move { 115 | hyper::Response::builder() 116 | .status(302) 117 | .header("location", HeaderValue::from_bytes(b"\xFF").unwrap()) 118 | .body("".into()) 119 | .unwrap() 120 | }); 121 | 122 | get_command() 123 | .args(["--follow", &server.base_url()]) 124 | .assert() 125 | .stderr("xh: warning: Redirect to invalid URL: \"\\xff\"\n"); 126 | } 127 | 128 | /// This test should fail if rustls's version gets out of sync in Cargo.toml. 129 | #[cfg(feature = "rustls")] 130 | #[test] 131 | fn rustls_emits_logs() { 132 | let mut server = server::http(|_req| async move { 133 | unreachable!(); 134 | }); 135 | server.disable_hit_checks(); 136 | let cmd = get_command() 137 | .arg("--debug") 138 | .arg(server.base_url().replace("http://", "https://")) 139 | .env_remove("RUST_LOG") 140 | .assert() 141 | .failure(); 142 | 143 | assert!(std::str::from_utf8(&cmd.get_output().stderr) 144 | .unwrap() 145 | .contains("rustls::")); 146 | } 147 | -------------------------------------------------------------------------------- /tests/cases/mod.rs: -------------------------------------------------------------------------------- 1 | mod compress_request_body; 2 | mod download; 3 | mod logging; 4 | -------------------------------------------------------------------------------- /tests/fixtures/certs/README.md: -------------------------------------------------------------------------------- 1 | # Test Fixtures: HTTPS Certificates 2 | 3 | ## Client certificate 4 | 5 | Source: https://github.com/jihchi/ht/pull/1#issuecomment-777902358 by [otaconix](https://github.com/otaconix) 6 | 7 | - `./client.badssl.com.crt` 8 | - `./client.badssl.com.key` 9 | 10 | ## Self-signed Certificate 11 | 12 | Source: https://github.com/chromium/badssl.com/blob/master/certs/sets/prod/pregen/chain/wildcard-self-signed.pem 13 | 14 | - `./wildcard-self-signed.pem` 15 | -------------------------------------------------------------------------------- /tests/fixtures/certs/client.badssl.com.crt: -------------------------------------------------------------------------------- 1 | Bag Attributes 2 | localKeyID: 41 C3 6C 33 C7 E3 36 DD EA 4A 1F C0 B7 23 B8 E6 9C DC D8 0F 3 | subject=C = US, ST = California, L = San Francisco, O = BadSSL, CN = BadSSL Client Certificate 4 | 5 | issuer=C = US, ST = California, L = San Francisco, O = BadSSL, CN = BadSSL Client Root Certificate Authority 6 | 7 | -----BEGIN CERTIFICATE----- 8 | MIIEqDCCApCgAwIBAgIUK5Ns4y2CzosB/ZoFlaxjZqoBTIIwDQYJKoZIhvcNAQEL 9 | BQAwfjELMAkGA1UEBhMCVVMxEzARBgNVBAgMCkNhbGlmb3JuaWExFjAUBgNVBAcM 10 | DVNhbiBGcmFuY2lzY28xDzANBgNVBAoMBkJhZFNTTDExMC8GA1UEAwwoQmFkU1NM 11 | IENsaWVudCBSb290IENlcnRpZmljYXRlIEF1dGhvcml0eTAeFw0xOTExMjcwMDE5 12 | NTdaFw0yMTExMjYwMDE5NTdaMG8xCzAJBgNVBAYTAlVTMRMwEQYDVQQIDApDYWxp 13 | Zm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ8wDQYDVQQKDAZCYWRTU0wx 14 | IjAgBgNVBAMMGUJhZFNTTCBDbGllbnQgQ2VydGlmaWNhdGUwggEiMA0GCSqGSIb3 15 | DQEBAQUAA4IBDwAwggEKAoIBAQDHN18R6x5Oz+u6SOXLoxIscz5GHR6cDcCLgyPa 16 | x2XfXHdJs+h6fTy61WGM+aXEhR2SIwbj5997s34m0MsbvkJrFmn0LHK1fuTLCihE 17 | EmxGdCGZA9xrwxFYAkEjP7D8v7cAWRMipYF/JP7VU7xNUo+QSkZ0sOi9k6bNkABK 18 | L3+yP6PqAzsBoKIN5lN/YRLrppsDmk6nrRDo4R3CD+8JQl9quEoOmL22Pc/qpOjL 19 | 1jgOIFSE5y3gwbzDlfCYoAL5V+by1vu0yJShTTK8oo5wvphcFfEHaQ9w5jFg2htd 20 | q99UER3BKuNDuL+zejqGQZCWb0Xsk8S5WBuX8l3Brrg5giqNAgMBAAGjLTArMAkG 21 | A1UdEwQCMAAwEQYJYIZIAYb4QgEBBAQDAgeAMAsGA1UdDwQEAwIF4DANBgkqhkiG 22 | 9w0BAQsFAAOCAgEAZBauLzFSOijkDadcippr9C6laHebb0oRS54xAV70E9k5GxfR 23 | /E2EMuQ8X+miRUMXxKquffcDsSxzo2ac0flw94hDx3B6vJIYvsQx9Lzo95Im0DdT 24 | DkHFXhTlv2kjQwFVnEsWYwyGpHMTjanvNkO7sBP9p1bN1qTE3QAeyMZNKWJk5xPl 25 | U298ERar6tl3Z2Cl8mO6yLhrq4ba6iPGw08SENxzuAJW+n8r0rq7EU+bMg5spgT1 26 | CxExzG8Bb0f98ZXMklpYFogkcuH4OUOFyRodotrotm3iRbuvZNk0Zz7N5n1oLTPl 27 | bGPMwBcqaGXvK62NlaRkwjnbkPM4MYvREM0bbAgZD2GHyANBTso8bdWvhLvmoSjs 28 | FSqJUJp17AZ0x/ELWZd69v2zKW9UdPmw0evyVR19elh/7dmtF6wbewc4N4jxQnTq 29 | IItuhIWKWB9edgJz65uZ9ubQWjXoa+9CuWcV/1KxuKCbLHdZXiboLrKm4S1WmMYW 30 | d0sJm95H9mJzcLyhLF7iX2kK6K9ug1y02YCVXBC9WGZc2x6GMS7lDkXSkJFy3EWh 31 | CmfxkmFGwOgwKt3Jd1pF9ftcSEMhu4WcMgxi9vZr9OdkJLxmk033sVKI/hnkPaHw 32 | g0Y2YBH5v0xmi8sYU7weOcwynkjZARpUltBUQ0pWCF5uJsEB8uE8PPDD3c4= 33 | -----END CERTIFICATE----- 34 | -------------------------------------------------------------------------------- /tests/fixtures/certs/client.badssl.com.key: -------------------------------------------------------------------------------- 1 | -----BEGIN RSA PRIVATE KEY----- 2 | MIIEowIBAAKCAQEAxzdfEeseTs/rukjly6MSLHM+Rh0enA3Ai4Mj2sdl31x3SbPo 3 | en08utVhjPmlxIUdkiMG4+ffe7N+JtDLG75CaxZp9CxytX7kywooRBJsRnQhmQPc 4 | a8MRWAJBIz+w/L+3AFkTIqWBfyT+1VO8TVKPkEpGdLDovZOmzZAASi9/sj+j6gM7 5 | AaCiDeZTf2ES66abA5pOp60Q6OEdwg/vCUJfarhKDpi9tj3P6qToy9Y4DiBUhOct 6 | 4MG8w5XwmKAC+Vfm8tb7tMiUoU0yvKKOcL6YXBXxB2kPcOYxYNobXavfVBEdwSrj 7 | Q7i/s3o6hkGQlm9F7JPEuVgbl/Jdwa64OYIqjQIDAQABAoIBAFUQf7fW/YoJnk5c 8 | 8kKRzyDL1Lt7k6Zu+NiZlqXEnutRQF5oQ8yJzXS5yH25296eOJI+AqMuT28ypZtN 9 | bGzcQOAZIgTxNcnp9Sf9nlPyyekLjY0Y6PXaxX0e+VFj0N8bvbiYUGNq6HCyC15r 10 | 8uvRZRvnm04YfEj20zLTWkxTG+OwJ6ZNha1vfq8z7MG5JTsZbP0g7e/LrEb3wI7J 11 | Zu9yHQUzq23HhfhpmLN/0l89YLtOaS8WNq4QvKYgZapw/0G1wWoWW4Y2/UpAxZ9r 12 | cqTBWSpCSCCgyWjiNhPbSJWfe/9J2bcanITLcvCLlPWGAHy1wpo9iBH57y7S+7YS 13 | 3yi7lgECgYEA8lwaRIChc38tmtQCNPtai/7uVDdeJe0uv8Jsg04FTF8KMYcD0V1g 14 | +T7rUPA+rTHwv8uAGLdzl4NW5Qryw18rDY+UivnaZkEdEsnlo3fc8MSQF78dDHCX 15 | nwmHfOmBnBoSbLl+W5ByHkJRHOnX+8qKq9ePNFUMf/hZNYuma9BCFBUCgYEA0m2p 16 | VDn12YdhFUUBIH91aD5cQIsBhkHFU4vqW4zBt6TsJpFciWbrBrTeRzeDou59aIsn 17 | zGBrLMykOY+EwwRku9KTVM4U791Z/NFbH89GqyUaicb4or+BXw5rGF8DmzSsDo0f 18 | ixJ9TVD5DmDi3c9ZQ7ljrtdSxPdA8kOoYPFsApkCgYEA08uZSPQAI6aoe/16UEK4 19 | Rk9qhz47kHlNuVZ27ehoyOzlQ5Lxyy0HacmKaxkILOLPuUxljTQEWAv3DAIdVI7+ 20 | WMN41Fq0eVe9yIWXoNtGwUGFirsA77YVSm5RcN++3GQMZedUfUAl+juKFvJkRS4j 21 | MTkXdGw+mDa3/wsjTGSa2mECgYABO6NCWxSVsbVf6oeXKSgG9FaWCjp4DuqZErjM 22 | 0IZSDSVVFIT2SSQXZffncuvSiJMziZ0yFV6LZKeRrsWYXu44K4Oxe4Oj5Cgi0xc1 23 | mIFRf2YoaIIMchLP+8Wk3ummfyiC7VDB/9m8Gj1bWDX8FrrvKqbq31gcz1YSFVNn 24 | PgLkAQKBgFzG8NdL8os55YcjBcOZMUs5QTKiQSyZM0Abab17k9JaqsU0jQtzeFsY 25 | FTiwh2uh6l4gdO/dGC/P0Vrp7F05NnO7oE4T+ojDzVQMnFpCBeL7x08GfUQkphEG 26 | m0Wqhhi8/24Sy934t5Txgkfoltg8ahkx934WjP6WWRnSAu+cf+vW 27 | -----END RSA PRIVATE KEY----- 28 | -------------------------------------------------------------------------------- /tests/fixtures/certs/wildcard-self-signed.pem: -------------------------------------------------------------------------------- 1 | -----BEGIN CERTIFICATE----- 2 | MIIDeTCCAmGgAwIBAgIJAIb7Tcjl3Q8YMA0GCSqGSIb3DQEBCwUAMGIxCzAJBgNV 3 | BAYTAlVTMRMwEQYDVQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNp 4 | c2NvMQ8wDQYDVQQKDAZCYWRTU0wxFTATBgNVBAMMDCouYmFkc3NsLmNvbTAeFw0x 5 | NjA4MDgyMTE3MDVaFw0xODA4MDgyMTE3MDVaMGIxCzAJBgNVBAYTAlVTMRMwEQYD 6 | VQQIDApDYWxpZm9ybmlhMRYwFAYDVQQHDA1TYW4gRnJhbmNpc2NvMQ8wDQYDVQQK 7 | DAZCYWRTU0wxFTATBgNVBAMMDCouYmFkc3NsLmNvbTCCASIwDQYJKoZIhvcNAQEB 8 | BQADggEPADCCAQoCggEBAMIE7PiM7gTCs9hQ1XBYzJMY61yoaEmwIrX5lZ6xKyx2 9 | PmzAS2BMTOqytMAPgLaw+XLJhgL5XEFdEyt/ccRLvOmULlA3pmccYYz2QULFRtMW 10 | hyefdOsKnRFSJiFzbIRMeVXk0WvoBj1IFVKtsyjbqv9u/2CVSndrOfEk0TG23U3A 11 | xPxTuW1CrbV8/q71FdIzSOciccfCFHpsKOo3St/qbLVytH5aohbcabFXRNsKEqve 12 | ww9HdFxBIuGa+RuT5q0iBikusbpJHAwnnqP7i/dAcgCskgjZjFeEU4EFy+b+a1SY 13 | QCeFxxC7c3DvaRhBB0VVfPlkPz0sw6l865MaTIbRyoUCAwEAAaMyMDAwCQYDVR0T 14 | BAIwADAjBgNVHREEHDAaggwqLmJhZHNzbC5jb22CCmJhZHNzbC5jb20wDQYJKoZI 15 | hvcNAQELBQADggEBALW4pad52T7VNw2nFMjPH98ZJNAQQgWyr3H2KlZN6IFGsonO 16 | nCC/Do8BPx6BnP3PFwovWMat1VvnRRoC8lw/30eEazWqBRGZWPz6LHTE3DNBJdc8 17 | xz6mh8q9RJX/PAj+YYGNElTu6qj49YT0BEhMF4U+dTQ0G8y3x4WNfiu9pGqyrp8d 18 | AzeidMfQ/pU01PpoPTDLvRDNkmMsABNE1fXBfJxDDGwfq1xY1j23Fm6BolwZC2y7 19 | n19h+vMYVWbGoovrf2/ibTvtcTyfDop7gl5Yy3OncZxokFj21rUZpLgx9ea4a9z3 20 | FzEz5ufynq03RhHTE1eu+gDzMEF0GNhGGsKqeA4= 21 | -----END CERTIFICATE----- 22 | -------------------------------------------------------------------------------- /tests/fixtures/responses/README.md: -------------------------------------------------------------------------------- 1 | # Test Fixtures: Compressed Responses 2 | 3 | ```sh 4 | $ echo "Hello world" > hello_world 5 | $ pigz hello_world # hello_world.gz 6 | 7 | $ echo "Hello world" > hello_world 8 | $ pigz -z hello_world # hello_world.zz 9 | 10 | $ echo "Hello world" > hello_world 11 | $ brotli hello_world # hello_world.br 12 | 13 | $ echo "Hello world" > hello_world 14 | $ zstd hello_world # hello_world.zst 15 | ``` 16 | -------------------------------------------------------------------------------- /tests/fixtures/responses/hello_world.br: -------------------------------------------------------------------------------- 1 | !,Hello world 2 |  -------------------------------------------------------------------------------- /tests/fixtures/responses/hello_world.gz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducaale/xh/2ac5bf963b19988c1fe510f2bc097b616127e85a/tests/fixtures/responses/hello_world.gz -------------------------------------------------------------------------------- /tests/fixtures/responses/hello_world.zst: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducaale/xh/2ac5bf963b19988c1fe510f2bc097b616127e85a/tests/fixtures/responses/hello_world.zst -------------------------------------------------------------------------------- /tests/fixtures/responses/hello_world.zz: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ducaale/xh/2ac5bf963b19988c1fe510f2bc097b616127e85a/tests/fixtures/responses/hello_world.zz -------------------------------------------------------------------------------- /tests/server/mod.rs: -------------------------------------------------------------------------------- 1 | // Copied from https://raw.githubusercontent.com/seanmonstar/reqwest/v0.12.0/tests/support/server.rs 2 | // with some slight tweaks 3 | use std::convert::Infallible; 4 | use std::future::Future; 5 | use std::net; 6 | use std::sync::mpsc as std_mpsc; 7 | use std::sync::{Arc, Mutex}; 8 | use std::thread; 9 | use std::time::Duration; 10 | 11 | use http_body_util::Full; 12 | use hyper::body::Bytes; 13 | use hyper::service::service_fn; 14 | use hyper::{Request, Response}; 15 | use tokio::runtime; 16 | use tokio::sync::oneshot; 17 | 18 | type Body = Full; 19 | type Builder = hyper_util::server::conn::auto::Builder; 20 | 21 | pub struct Server { 22 | addr: net::SocketAddr, 23 | panic_rx: std_mpsc::Receiver<()>, 24 | successful_hits: Arc>, 25 | total_hits: Arc>, 26 | no_hit_checks: bool, 27 | shutdown_tx: Option>, 28 | } 29 | 30 | impl Server { 31 | pub fn base_url(&self) -> String { 32 | format!("http://{}", self.addr) 33 | } 34 | 35 | pub fn url(&self, path: &str) -> String { 36 | format!("http://{}{}", self.addr, path) 37 | } 38 | 39 | pub fn host(&self) -> String { 40 | String::from("127.0.0.1") 41 | } 42 | 43 | pub fn port(&self) -> u16 { 44 | self.addr.port() 45 | } 46 | 47 | pub fn assert_hits(&self, hits: u8) { 48 | assert_eq!(*self.successful_hits.lock().unwrap(), hits); 49 | } 50 | 51 | pub fn disable_hit_checks(&mut self) { 52 | self.no_hit_checks = true; 53 | } 54 | } 55 | 56 | impl Drop for Server { 57 | fn drop(&mut self) { 58 | if let Some(tx) = self.shutdown_tx.take() { 59 | let _ = tx.send(()); 60 | } 61 | 62 | if !std::thread::panicking() && !self.no_hit_checks { 63 | let total_hits = *self.total_hits.lock().unwrap(); 64 | let successful_hits = *self.successful_hits.lock().unwrap(); 65 | let failed_hits = total_hits - successful_hits; 66 | assert!(total_hits > 0, "test server exited without being called"); 67 | assert_eq!( 68 | failed_hits, 0, 69 | "numbers of panicked or in-progress requests: {}", 70 | failed_hits 71 | ); 72 | } 73 | 74 | if !std::thread::panicking() { 75 | self.panic_rx 76 | .recv_timeout(Duration::from_secs(3)) 77 | .expect("test server should not panic"); 78 | } 79 | } 80 | } 81 | 82 | // http() is generic, http_inner() is not. 83 | // A generic function has to be compiled for every single type you use it with. 84 | // And every closure counts as a different type. 85 | // By making only http() generic a rebuild of the tests take 3-10 times less long. 86 | 87 | pub fn http(func: F) -> Server 88 | where 89 | F: Fn(Request) -> Fut + Send + Sync + 'static, 90 | Fut: Future> + Send + 'static, 91 | { 92 | http_inner(Arc::new(move |req| Box::new(Box::pin(func(req))))) 93 | } 94 | 95 | type Serv = dyn Fn(Request) -> Box + Send + Sync; 96 | type ServFut = dyn Future> + Send + Unpin; 97 | 98 | fn http_inner(func: Arc) -> Server { 99 | // Spawn new runtime in thread to prevent reactor execution context conflict 100 | thread::spawn(move || { 101 | let rt = runtime::Builder::new_current_thread() 102 | .enable_all() 103 | .build() 104 | .expect("new rt"); 105 | let successful_hits = Arc::new(Mutex::new(0)); 106 | let total_hits = Arc::new(Mutex::new(0)); 107 | let listener = rt.block_on(async move { 108 | tokio::net::TcpListener::bind(&std::net::SocketAddr::from(([127, 0, 0, 1], 0))) 109 | .await 110 | .unwrap() 111 | }); 112 | let addr = listener.local_addr().unwrap(); 113 | 114 | let (shutdown_tx, shutdown_rx) = oneshot::channel(); 115 | let (panic_tx, panic_rx) = std_mpsc::channel(); 116 | let thread_name = format!( 117 | "test({})-support-server", 118 | thread::current().name().unwrap_or("") 119 | ); 120 | 121 | { 122 | let successful_hits = successful_hits.clone(); 123 | let total_hits = total_hits.clone(); 124 | thread::Builder::new() 125 | .name(thread_name) 126 | .spawn(move || { 127 | let task = rt.spawn(async move { 128 | let builder = Builder::new(hyper_util::rt::TokioExecutor::new()); 129 | loop { 130 | let svc = { 131 | let func = func.clone(); 132 | let successful_hits = successful_hits.clone(); 133 | let total_hits = total_hits.clone(); 134 | 135 | service_fn(move |req| { 136 | let successful_hits = successful_hits.clone(); 137 | let total_hits = total_hits.clone(); 138 | let fut = func(req); 139 | async move { 140 | *total_hits.lock().unwrap() += 1; 141 | let res = fut.await; 142 | *successful_hits.lock().unwrap() += 1; 143 | Ok::<_, Infallible>(res) 144 | } 145 | }) 146 | }; 147 | 148 | let (io, _) = listener.accept().await.unwrap(); 149 | 150 | let builder = builder.clone(); 151 | tokio::spawn(async move { 152 | let _ = builder 153 | .serve_connection(hyper_util::rt::TokioIo::new(io), svc) 154 | .await; 155 | }); 156 | } 157 | }); 158 | let _ = rt.block_on(shutdown_rx); 159 | task.abort(); 160 | let _ = panic_tx.send(()); 161 | }) 162 | .expect("thread spawn"); 163 | } 164 | Server { 165 | addr, 166 | panic_rx, 167 | shutdown_tx: Some(shutdown_tx), 168 | successful_hits, 169 | total_hits, 170 | no_hit_checks: false, 171 | } 172 | }) 173 | .join() 174 | .unwrap() 175 | } 176 | --------------------------------------------------------------------------------