├── .github └── workflows │ ├── ci.yml │ └── publish-binaries.yml ├── .gitignore ├── CONTRIBUTING.md ├── Cargo.lock ├── Cargo.toml ├── Cross.toml ├── LICENSE ├── ORG_CODE_OF_CONDUCT.md ├── README.md ├── SECURITY.md └── crates ├── wasm-pkg-client ├── Cargo.toml ├── README.md ├── src │ ├── caching │ │ ├── file.rs │ │ └── mod.rs │ ├── lib.rs │ ├── loader.rs │ ├── local.rs │ ├── oci │ │ ├── config.rs │ │ ├── loader.rs │ │ ├── mod.rs │ │ └── publisher.rs │ ├── publisher.rs │ ├── release.rs │ └── warg │ │ ├── config.rs │ │ ├── loader.rs │ │ ├── mod.rs │ │ └── publisher.rs └── tests │ ├── e2e.rs │ └── testdata │ └── binary_wit.wasm ├── wasm-pkg-common ├── Cargo.toml └── src │ ├── config.rs │ ├── config │ └── toml.rs │ ├── digest.rs │ ├── label.rs │ ├── lib.rs │ ├── metadata.rs │ ├── package.rs │ └── registry.rs ├── wasm-pkg-core ├── Cargo.toml ├── src │ ├── config.rs │ ├── lib.rs │ ├── lock.rs │ ├── resolver.rs │ └── wit.rs └── tests │ ├── build.rs │ ├── common.rs │ ├── fetch.rs │ └── fixtures │ ├── cli-example │ ├── .cargo │ │ └── config.toml │ ├── .gitignore │ ├── Cargo.toml │ ├── src │ │ └── lib.rs │ └── wit │ │ └── world.wit │ ├── dog-fetcher │ ├── .cargo │ │ └── config.toml │ ├── .gitignore │ ├── Cargo.toml │ ├── src │ │ └── lib.rs │ └── wit │ │ └── world.wit │ ├── nested-local │ ├── local-dep │ │ └── wit │ │ │ └── world.wit │ └── project │ │ └── wit │ │ └── world.wit │ ├── transitive-local │ ├── example-a │ │ └── wit │ │ │ └── world.wit │ ├── example-b │ │ └── wit │ │ │ └── world.wit │ └── example-c │ │ └── wit │ │ └── world.wit │ └── wasi-http │ ├── .gitignore │ └── wit │ ├── handler.wit │ ├── proxy.wit │ └── types.wit └── wkg ├── Cargo.toml ├── src ├── main.rs ├── oci.rs └── wit.rs └── tests ├── common.rs ├── e2e.rs └── fixtures └── wasi-http ├── .gitignore ├── wit ├── handler.wit ├── proxy.wit └── types.wit └── wkg.toml /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | on: [push, pull_request] 2 | 3 | name: CI 4 | 5 | jobs: 6 | run-ci: 7 | name: Run CI 8 | runs-on: ${{ matrix.os }} 9 | strategy: 10 | matrix: 11 | include: 12 | - os: ubuntu-latest 13 | additional_test_flags: "" 14 | - os: windows-latest 15 | additional_test_flags: "--no-default-features" 16 | - os: macos-latest 17 | additional_test_flags: "--no-default-features" 18 | steps: 19 | - uses: actions/checkout@v4 20 | - uses: dtolnay/rust-toolchain@stable 21 | with: 22 | targets: "wasm32-wasip1" 23 | # We have to run these separately so we can deactivate a feature for one of the tests 24 | - name: Run client tests 25 | working-directory: ./crates/wasm-pkg-client 26 | run: cargo test ${{ matrix.additional_test_flags }} 27 | - name: Run wkg tests 28 | working-directory: ./crates/wkg 29 | run: cargo test ${{ matrix.additional_test_flags }} 30 | - name: Run other tests 31 | run: cargo test --workspace --exclude wasm-pkg-client --exclude wkg 32 | - name: Run cargo clippy 33 | run: cargo clippy --all --workspace -------------------------------------------------------------------------------- /.github/workflows/publish-binaries.yml: -------------------------------------------------------------------------------- 1 | name: "Publish binaries" 2 | 3 | on: 4 | push: 5 | branches: ["main"] 6 | tags: 7 | - "v*" 8 | 9 | jobs: 10 | bump_dev_release: 11 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'bytecodealliance/wasm-pkg-tools' 12 | name: Create dev release 13 | runs-on: ubuntu-latest 14 | permissions: 15 | contents: write 16 | steps: 17 | - name: Login GH CLI 18 | run: gh auth login --with-token < <(echo ${{ secrets.GITHUB_TOKEN }}) 19 | - name: Delete old dev release 20 | run: gh release delete -R bytecodealliance/wasm-pkg-tools dev -y || true 21 | - name: Create new latest release 22 | run: gh release create -R bytecodealliance/wasm-pkg-tools dev --prerelease --notes "Published artifacts from the latest build" 23 | 24 | publish_dev_release: 25 | if: github.event_name == 'push' && github.ref == 'refs/heads/main' && github.repository == 'bytecodealliance/wasm-pkg-tools' 26 | name: Publish to dev release 27 | runs-on: ${{ matrix.os }} 28 | strategy: 29 | matrix: 30 | include: 31 | - rust-target: x86_64-unknown-linux-gnu 32 | os: ubuntu-latest 33 | - rust-target: aarch64-unknown-linux-gnu 34 | os: ubuntu-latest 35 | cross: true 36 | - rust-target: x86_64-apple-darwin 37 | os: macos-latest 38 | - rust-target: aarch64-apple-darwin 39 | os: macos-latest 40 | - rust-target: x86_64-pc-windows-gnu 41 | os: windows-latest 42 | 43 | needs: 44 | - bump_dev_release 45 | permissions: 46 | contents: write 47 | steps: 48 | - uses: actions/checkout@v4 49 | - name: Install Rust 50 | run: rustup update stable --no-self-update && rustup default stable && rustup target add ${{ matrix.rust-target }} 51 | - run: cargo build --release --target ${{ matrix.rust-target }} 52 | if: ${{ ! matrix.cross }} 53 | - run: cargo install cross 54 | if: ${{ matrix.cross }} 55 | - run: cross build --release --target ${{ matrix.rust-target }} 56 | if: ${{ matrix.cross }} 57 | - run: mv ./target/${{ matrix.rust-target }}/release/wkg.exe ./target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 58 | if: matrix.os == 'windows-latest' 59 | - run: mv ./target/${{ matrix.rust-target }}/release/wkg ./target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 60 | if: matrix.os != 'windows-latest' 61 | - name: Login GH CLI 62 | shell: bash 63 | run: gh auth login --with-token < <(echo ${{ secrets.GITHUB_TOKEN }}) 64 | - run: gh release upload -R bytecodealliance/wasm-pkg-tools --clobber dev target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 65 | 66 | create_tagged_release: 67 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'bytecodealliance/wasm-pkg-tools' 68 | name: Create tagged release 69 | runs-on: ubuntu-latest 70 | permissions: 71 | contents: write 72 | steps: 73 | - name: Login GH CLI 74 | shell: bash 75 | run: gh auth login --with-token < <(echo ${{ secrets.GITHUB_TOKEN }}) 76 | - run: gh release create -R bytecodealliance/wasm-pkg-tools ${{ github.ref_name }} 77 | 78 | publish_tagged_release: 79 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'bytecodealliance/wasm-pkg-tools' 80 | name: Publish to tagged release 81 | runs-on: ${{ matrix.os }} 82 | needs: 83 | - create_tagged_release 84 | strategy: 85 | matrix: 86 | include: 87 | - rust-target: x86_64-unknown-linux-gnu 88 | os: ubuntu-latest 89 | - rust-target: aarch64-unknown-linux-gnu 90 | os: ubuntu-latest 91 | cross: true 92 | - rust-target: x86_64-apple-darwin 93 | os: macos-latest 94 | - rust-target: aarch64-apple-darwin 95 | os: macos-latest 96 | - rust-target: x86_64-pc-windows-gnu 97 | os: windows-latest 98 | permissions: 99 | contents: write 100 | steps: 101 | - uses: actions/checkout@v4 102 | - name: Install Rust 103 | run: rustup update stable --no-self-update && rustup default stable && rustup target add ${{ matrix.rust-target }} 104 | - run: cargo build --release --target ${{ matrix.rust-target }} 105 | if: ${{ ! matrix.cross }} 106 | - run: cargo install cross 107 | if: ${{ matrix.cross }} 108 | - run: cross build --release --target ${{ matrix.rust-target }} 109 | if: ${{ matrix.cross }} 110 | - run: mv ./target/${{ matrix.rust-target }}/release/wkg.exe ./target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 111 | if: matrix.os == 'windows-latest' 112 | - run: mv ./target/${{ matrix.rust-target }}/release/wkg ./target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 113 | if: matrix.os != 'windows-latest' 114 | - name: Login GH CLI 115 | shell: bash 116 | run: gh auth login --with-token < <(echo ${{ secrets.GITHUB_TOKEN }}) 117 | - run: gh release upload -R bytecodealliance/wasm-pkg-tools --clobber ${{ github.ref_name }} target/${{ matrix.rust-target }}/release/wkg-${{ matrix.rust-target }} 118 | 119 | publish_crates: 120 | if: github.event_name == 'push' && startsWith(github.ref, 'refs/tags/v') && github.repository == 'bytecodealliance/wasm-pkg-tools' 121 | name: Publish to crates.io 122 | runs-on: ubuntu-latest 123 | env: 124 | CARGO_REGISTRY_TOKEN: ${{ secrets.CARGO_REGISTRY_TOKEN }} 125 | steps: 126 | - uses: actions/checkout@v4 127 | - name: Install Rust 128 | run: rustup update stable --no-self-update && rustup default stable 129 | - name: Publish wasm-pkg-common 130 | working-directory: ./crates/wasm-pkg-common 131 | run: cargo publish 132 | - name: Publish wasm-pkg-client 133 | working-directory: ./crates/wasm-pkg-client 134 | run: cargo publish 135 | - name: Publish wasm-pkg-core 136 | working-directory: ./crates/wasm-pkg-core 137 | run: cargo publish 138 | - name: Publish wkg 139 | working-directory: ./crates/wkg 140 | run: cargo publish 141 | 142 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | target/ 2 | .vscode 3 | *.swp 4 | .direnv/ 5 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | # Contributing 2 | 3 | This project welcomes contributions for bug fixes, documentation updates, new 4 | features, or whatever you might like. Development is done through GitHub pull 5 | requests. Feel free to reach out on the [Bytecode Alliance 6 | Zulip](https://bytecodealliance.zulipchat.com/) as well if you'd like assistance 7 | in contributing or would just like to say hi. 8 | 9 | # License 10 | 11 | This project is licensed under the Apache 2.0 license with the LLVM exception. 12 | See [LICENSE](LICENSE) for more details. 13 | 14 | ### Contribution 15 | 16 | Unless you explicitly state otherwise, any contribution intentionally submitted 17 | for inclusion in this project by you, as defined in the Apache-2.0 license, 18 | shall be licensed as above, without any additional terms or conditions. 19 | -------------------------------------------------------------------------------- /Cargo.toml: -------------------------------------------------------------------------------- 1 | [workspace] 2 | members = ["crates/*"] 3 | resolver = "2" 4 | 5 | [workspace.package] 6 | edition = "2021" 7 | version = "0.10.0" 8 | authors = ["The Wasmtime Project Developers"] 9 | license = "Apache-2.0 WITH LLVM-exception" 10 | 11 | [workspace.dependencies] 12 | anyhow = "1" 13 | base64 = "0.22" 14 | bytes = "1.8" 15 | docker_credential = "1.2.1" 16 | etcetera = "0.8" 17 | futures-util = "0.3.30" 18 | oci-client = { version = "0.14", default-features = false, features = [ 19 | "rustls-tls", 20 | ] } 21 | oci-wasm = { version = "0.2.1", default-features = false, features = [ 22 | "rustls-tls", 23 | ] } 24 | semver = "1.0.23" 25 | serde = { version = "1.0", features = ["derive"] } 26 | serde_json = "1" 27 | sha2 = "0.10" 28 | tempfile = "3.10.1" 29 | testcontainers = { version = "0.23" } 30 | thiserror = "1.0" 31 | tokio = "1.44.2" 32 | tokio-util = "0.7.10" 33 | toml = "0.8" 34 | tracing = "0.1.40" 35 | tracing-subscriber = { version = "0.3.18", default-features = false, features = [ 36 | "fmt", 37 | "env-filter", 38 | ] } 39 | wasm-pkg-common = { version = "0.10.0", path = "crates/wasm-pkg-common" } 40 | wasm-pkg-client = { version = "0.10.0", path = "crates/wasm-pkg-client" } 41 | wasm-pkg-core = { version = "0.10.0", path = "crates/wasm-pkg-core" } 42 | wasm-metadata = "0.227" 43 | wit-component = "0.227" 44 | wit-parser = "0.227" 45 | -------------------------------------------------------------------------------- /Cross.toml: -------------------------------------------------------------------------------- 1 | [target.aarch64-unknown-linux-gnu] 2 | pre-build = [ 3 | "dpkg --add-architecture $CROSS_DEB_ARCH", 4 | "apt-get update && apt-get --assume-yes install libssl-dev:$CROSS_DEB_ARCH", 5 | ] 6 | env.passthrough = [ 7 | "OPENSSL_LIB_DIR=/usr/lib/aarch64-linux-gnu", 8 | "OPENSSL_INCLUDE_DIR=/usr/include/aarch64-linux-gnu/openssl", 9 | "OPENSSL_STATIC=yes" 10 | ] 11 | image = "ghcr.io/cross-rs/aarch64-unknown-linux-gnu:edge" 12 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. 203 | 204 | 205 | --- LLVM Exceptions to the Apache 2.0 License ---- 206 | 207 | As an exception, if, as a result of your compiling your source code, portions 208 | of this Software are embedded into an Object form of such source code, you 209 | may redistribute such embedded portions in such Object form without complying 210 | with the conditions of Sections 4(a), 4(b) and 4(d) of the License. 211 | 212 | In addition, if you combine or link compiled forms of this Software with 213 | software that is licensed under the GPLv2 ("Combined Software") and if a 214 | court of competent jurisdiction determines that the patent provision (Section 215 | 3), the indemnity provision (Section 9) or other Section of the License 216 | conflicts with the conditions of the GPLv2, you may retroactively and 217 | prospectively choose to deem waived or otherwise exclude such Section(s) of 218 | the License, but only in their entirety and only with respect to the Combined 219 | Software. 220 | 221 | -------------------------------------------------------------------------------- /ORG_CODE_OF_CONDUCT.md: -------------------------------------------------------------------------------- 1 | # Bytecode Alliance Organizational Code of Conduct (OCoC) 2 | 3 | *Note*: this Code of Conduct pertains to organizations' behavior. Please also see the [Individual Code of Conduct](CODE_OF_CONDUCT.md). 4 | 5 | ## Preamble 6 | 7 | The Bytecode Alliance (BA) welcomes involvement from organizations, 8 | including commercial organizations. This document is an 9 | *organizational* code of conduct, intended particularly to provide 10 | guidance to commercial organizations. It is distinct from the 11 | [Individual Code of Conduct (ICoC)](CODE_OF_CONDUCT.md), and does not 12 | replace the ICoC. This OCoC applies to any group of people acting in 13 | concert as a BA member or as a participant in BA activities, whether 14 | or not that group is formally incorporated in some jurisdiction. 15 | 16 | The code of conduct described below is not a set of rigid rules, and 17 | we did not write it to encompass every conceivable scenario that might 18 | arise. For example, it is theoretically possible there would be times 19 | when asserting patents is in the best interest of the BA community as 20 | a whole. In such instances, consult with the BA, strive for 21 | consensus, and interpret these rules with an intent that is generous 22 | to the community the BA serves. 23 | 24 | While we may revise these guidelines from time to time based on 25 | real-world experience, overall they are based on a simple principle: 26 | 27 | *Bytecode Alliance members should observe the distinction between 28 | public community functions and private functions — especially 29 | commercial ones — and should ensure that the latter support, or at 30 | least do not harm, the former.* 31 | 32 | ## Guidelines 33 | 34 | * **Do not cause confusion about Wasm standards or interoperability.** 35 | 36 | Having an interoperable WebAssembly core is a high priority for 37 | the BA, and members should strive to preserve that core. It is fine 38 | to develop additional non-standard features or APIs, but they 39 | should always be clearly distinguished from the core interoperable 40 | Wasm. 41 | 42 | Treat the WebAssembly name and any BA-associated names with 43 | respect, and follow BA trademark and branding guidelines. If you 44 | distribute a customized version of software originally produced by 45 | the BA, or if you build a product or service using BA-derived 46 | software, use names that clearly distinguish your work from the 47 | original. (You should still provide proper attribution to the 48 | original, of course, wherever such attribution would normally be 49 | given.) 50 | 51 | Further, do not use the WebAssembly name or BA-associated names in 52 | other public namespaces in ways that could cause confusion, e.g., 53 | in company names, names of commercial service offerings, domain 54 | names, publicly-visible social media accounts or online service 55 | accounts, etc. It may sometimes be reasonable, however, to 56 | register such a name in a new namespace and then immediately donate 57 | control of that account to the BA, because that would help the project 58 | maintain its identity. 59 | 60 | * **Do not restrict contributors.** If your company requires 61 | employees or contractors to sign non-compete agreements, those 62 | agreements must not prevent people from participating in the BA or 63 | contributing to related projects. 64 | 65 | This does not mean that all non-compete agreements are incompatible 66 | with this code of conduct. For example, a company may restrict an 67 | employee's ability to solicit the company's customers. However, an 68 | agreement must not block any form of technical or social 69 | participation in BA activities, including but not limited to the 70 | implementation of particular features. 71 | 72 | The accumulation of experience and expertise in individual persons, 73 | who are ultimately free to direct their energy and attention as 74 | they decide, is one of the most important drivers of progress in 75 | open source projects. A company that limits this freedom may hinder 76 | the success of the BA's efforts. 77 | 78 | * **Do not use patents as offensive weapons.** If any BA participant 79 | prevents the adoption or development of BA technologies by 80 | asserting its patents, that undermines the purpose of the 81 | coalition. The collaboration fostered by the BA cannot include 82 | members who act to undermine its work. 83 | 84 | * **Practice responsible disclosure** for security vulnerabilities. 85 | Use designated, non-public reporting channels to disclose technical 86 | vulnerabilities, and give the project a reasonable period to 87 | respond, remediate, and patch. 88 | 89 | Vulnerability reporters may patch their company's own offerings, as 90 | long as that patching does not significantly delay the reporting of 91 | the vulnerability. Vulnerability information should never be used 92 | for unilateral commercial advantage. Vendors may legitimately 93 | compete on the speed and reliability with which they deploy 94 | security fixes, but withholding vulnerability information damages 95 | everyone in the long run by risking harm to the BA project's 96 | reputation and to the security of all users. 97 | 98 | * **Respect the letter and spirit of open source practice.** While 99 | there is not space to list here all possible aspects of standard 100 | open source practice, some examples will help show what we mean: 101 | 102 | * Abide by all applicable open source license terms. Do not engage 103 | in copyright violation or misattribution of any kind. 104 | 105 | * Do not claim others' ideas or designs as your own. 106 | 107 | * When others engage in publicly visible work (e.g., an upcoming 108 | demo that is coordinated in a public issue tracker), do not 109 | unilaterally announce early releases or early demonstrations of 110 | that work ahead of their schedule in order to secure private 111 | advantage (such as marketplace advantage) for yourself. 112 | 113 | The BA reserves the right to determine what constitutes good open 114 | source practices and to take action as it deems appropriate to 115 | encourage, and if necessary enforce, such practices. 116 | 117 | ## Enforcement 118 | 119 | Instances of organizational behavior in violation of the OCoC may 120 | be reported by contacting the Bytecode Alliance CoC team at 121 | [report@bytecodealliance.org](mailto:report@bytecodealliance.org). The 122 | CoC team will review and investigate all complaints, and will respond 123 | in a way that it deems appropriate to the circumstances. The CoC team 124 | is obligated to maintain confidentiality with regard to the reporter of 125 | an incident. Further details of specific enforcement policies may be 126 | posted separately. 127 | 128 | When the BA deems an organization in violation of this OCoC, the BA 129 | will, at its sole discretion, determine what action to take. The BA 130 | will decide what type, degree, and duration of corrective action is 131 | needed, if any, before a violating organization can be considered for 132 | membership (if it was not already a member) or can have its membership 133 | reinstated (if it was a member and the BA canceled its membership due 134 | to the violation). 135 | 136 | In practice, the BA's first approach will be to start a conversation, 137 | with punitive enforcement used only as a last resort. Violations 138 | often turn out to be unintentional and swiftly correctable with all 139 | parties acting in good faith. 140 | -------------------------------------------------------------------------------- /SECURITY.md: -------------------------------------------------------------------------------- 1 | # Security Policy 2 | 3 | Building secure foundations for software development is at the core of what we do in the Bytecode Alliance. Contributions of external security researchers are a vital part of that. 4 | 5 | ## Scope 6 | 7 | If you believe you've found a security issue in any website, service, or software owned or operated by the Bytecode Alliance, we encourage you to notify us. 8 | 9 | ## How to Submit a Report 10 | 11 | To submit a vulnerability report to the Bytecode Alliance, please contact us at [security@bytecodealliance.org](mailto:security@bytecodealliance.org). Your submission will be reviewed and validated by a member of our security team. 12 | 13 | ## Safe Harbor 14 | 15 | The Bytecode Alliance supports safe harbor for security researchers who: 16 | 17 | * Make a good faith effort to avoid privacy violations, destruction of data, and interruption or degradation of our services. 18 | * Only interact with accounts you own or with explicit permission of the account holder. If you do encounter Personally Identifiable Information (PII) contact us immediately, do not proceed with access, and immediately purge any local information. 19 | * Provide us with a reasonable amount of time to resolve vulnerabilities prior to any disclosure to the public or a third-party. 20 | 21 | We will consider activities conducted consistent with this policy to constitute "authorized" conduct and will not pursue civil action or initiate a complaint to law enforcement. We will help to the extent we can if legal action is initiated by a third party against you. 22 | 23 | Please submit a report to us before engaging in conduct that may be inconsistent with or unaddressed by this policy. 24 | 25 | ## Preferences 26 | 27 | * Please provide detailed reports with reproducible steps and a clearly defined impact. 28 | * Submit one vulnerability per report. 29 | * Social engineering (e.g. phishing, vishing, smishing) is prohibited. 30 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasm-pkg-client" 3 | description = "Wasm package client" 4 | repository = "https://github.com/bytecodealliance/wasm-pkg-tools/tree/main/crates/wasm-pkg-client" 5 | edition.workspace = true 6 | version.workspace = true 7 | authors.workspace = true 8 | license.workspace = true 9 | readme = "../../README.md" 10 | 11 | [features] 12 | default = ["_local"] 13 | # An internal feature for making sure e2e tests can run locally but not in CI for Mac or Windows 14 | _local = [] 15 | 16 | [dependencies] 17 | anyhow = { workspace = true } 18 | async-trait = "0.1.77" 19 | base64 = { workspace = true } 20 | bytes = { workspace = true } 21 | docker_credential = { workspace = true } 22 | etcetera = { workspace = true } 23 | futures-util = { workspace = true, features = ["io"] } 24 | oci-client = { workspace = true } 25 | oci-wasm = { workspace = true } 26 | secrecy = { version = "0.8", features = ["serde"] } 27 | serde = { workspace = true } 28 | serde_json = { workspace = true } 29 | sha2 = { workspace = true } 30 | thiserror = { workspace = true } 31 | tokio = { workspace = true, features = ["rt", "macros"] } 32 | tokio-util = { workspace = true, features = ["io", "io-util", "codec"] } 33 | toml = { workspace = true } 34 | tracing = { workspace = true } 35 | tracing-subscriber = { workspace = true } 36 | url = "2.5.0" 37 | warg-client = "0.9.2" 38 | warg-crypto = "0.9.2" 39 | wasm-metadata = { workspace = true } 40 | warg-protocol = "0.9.2" 41 | wasm-pkg-common = { workspace = true, features = ["metadata-client", "tokio"] } 42 | wit-component = { workspace = true } 43 | 44 | [dev-dependencies] 45 | tempfile = { workspace = true } 46 | testcontainers = { workspace = true } 47 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/README.md: -------------------------------------------------------------------------------- 1 | # Wasm Package Client 2 | 3 | A minimal Package Registry interface for multiple registry backends. 4 | 5 | ## Running Tests 6 | 7 | The e2e tests require an [OCI Distribution 8 | Spec](https://github.com/opencontainers/distribution-spec)-compliant registry to 9 | be running at `localhost:5000`. An ephemeral registry can be run with: 10 | 11 | ```console 12 | $ docker run --rm -p 5000:5000 distribution/distribution:edge 13 | ``` 14 | 15 | The e2e tests themselves are in the separate [`tests/e2e`](./tests/e2e/) crate: 16 | 17 | ```console 18 | $ cd tests/e2e 19 | $ cargo run 20 | ``` 21 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/caching/file.rs: -------------------------------------------------------------------------------- 1 | //! A `Cache` implementation for a filesystem 2 | 3 | use std::path::{Path, PathBuf}; 4 | 5 | use anyhow::Context; 6 | use etcetera::BaseStrategy; 7 | use futures_util::{StreamExt, TryStreamExt}; 8 | use tokio_util::io::{ReaderStream, StreamReader}; 9 | use wasm_pkg_common::{ 10 | digest::ContentDigest, 11 | package::{PackageRef, Version}, 12 | Error, 13 | }; 14 | 15 | use crate::{ContentStream, Release}; 16 | 17 | use super::Cache; 18 | 19 | pub struct FileCache { 20 | root: PathBuf, 21 | } 22 | 23 | impl FileCache { 24 | /// Creates a new file cache that stores data in the given directory. 25 | pub async fn new(root: impl AsRef) -> anyhow::Result { 26 | tokio::fs::create_dir_all(&root) 27 | .await 28 | .context("Unable to create cache directory")?; 29 | Ok(Self { 30 | root: root.as_ref().to_path_buf(), 31 | }) 32 | } 33 | 34 | /// Returns a cache setup to use the global default cache path if it can be determined, 35 | /// otherwise this will error 36 | pub async fn global_cache() -> anyhow::Result { 37 | Self::new(Self::global_cache_path().context("couldn't find global cache path")?).await 38 | } 39 | 40 | /// Returns the global default cache path if it can be determined, otherwise returns None 41 | pub fn global_cache_path() -> Option { 42 | etcetera::choose_base_strategy() 43 | .ok() 44 | .map(|strat| strat.cache_dir().join("wasm-pkg")) 45 | } 46 | } 47 | 48 | #[derive(serde::Serialize)] 49 | struct ReleaseInfoBorrowed<'a> { 50 | version: &'a Version, 51 | content_digest: &'a ContentDigest, 52 | } 53 | 54 | impl<'a> From<&'a Release> for ReleaseInfoBorrowed<'a> { 55 | fn from(release: &'a Release) -> Self { 56 | Self { 57 | version: &release.version, 58 | content_digest: &release.content_digest, 59 | } 60 | } 61 | } 62 | 63 | #[derive(serde::Deserialize)] 64 | struct ReleaseInfoOwned { 65 | version: Version, 66 | content_digest: ContentDigest, 67 | } 68 | 69 | impl From for Release { 70 | fn from(info: ReleaseInfoOwned) -> Self { 71 | Self { 72 | version: info.version, 73 | content_digest: info.content_digest, 74 | } 75 | } 76 | } 77 | 78 | impl Cache for FileCache { 79 | async fn put_data(&self, digest: ContentDigest, data: ContentStream) -> Result<(), Error> { 80 | let path = self.root.join(digest.to_string()); 81 | let mut file = tokio::fs::File::create(&path).await.map_err(|e| { 82 | Error::CacheError(anyhow::anyhow!("Unable to create file for cache {e}")) 83 | })?; 84 | let mut buf = 85 | StreamReader::new(data.map_err(|e| std::io::Error::new(std::io::ErrorKind::Other, e))); 86 | tokio::io::copy(&mut buf, &mut file) 87 | .await 88 | .map_err(|e| Error::CacheError(e.into())) 89 | .map(|_| ()) 90 | } 91 | 92 | async fn get_data(&self, digest: &ContentDigest) -> Result, Error> { 93 | let path = self.root.join(digest.to_string()); 94 | let exists = tokio::fs::try_exists(&path) 95 | .await 96 | .map_err(|e| Error::CacheError(e.into()))?; 97 | if !exists { 98 | return Ok(None); 99 | } 100 | let file = tokio::fs::File::open(path) 101 | .await 102 | .map_err(|e| Error::CacheError(e.into()))?; 103 | 104 | Ok(Some( 105 | ReaderStream::new(file).map_err(Error::IoError).boxed(), 106 | )) 107 | } 108 | 109 | async fn put_release(&self, package: &PackageRef, release: &Release) -> Result<(), Error> { 110 | let path = self 111 | .root 112 | .join(format!("{}-{}.json", package, release.version)); 113 | tokio::fs::write( 114 | path, 115 | serde_json::to_string(&ReleaseInfoBorrowed::from(release)).map_err(|e| { 116 | Error::CacheError(anyhow::anyhow!("Error serializing data to disk: {e}")) 117 | })?, 118 | ) 119 | .await 120 | .map(|_| ()) 121 | .map_err(|e| Error::CacheError(anyhow::anyhow!("Error writing to disk: {e}"))) 122 | } 123 | 124 | async fn get_release( 125 | &self, 126 | package: &PackageRef, 127 | version: &Version, 128 | ) -> Result, Error> { 129 | let path = self.root.join(format!("{}-{}.json", package, version)); 130 | let exists = tokio::fs::try_exists(&path).await.map_err(|e| { 131 | Error::CacheError(anyhow::anyhow!("Error checking if file exists: {e}")) 132 | })?; 133 | if !exists { 134 | return Ok(None); 135 | } 136 | let data = tokio::fs::read(path) 137 | .await 138 | .map_err(|e| Error::CacheError(anyhow::anyhow!("Error reading from disk: {e}")))?; 139 | let release: ReleaseInfoOwned = serde_json::from_slice(&data).map_err(|e| { 140 | Error::CacheError(anyhow::anyhow!("Error deserializing data from disk: {e}")) 141 | })?; 142 | Ok(Some(release.into())) 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/caching/mod.rs: -------------------------------------------------------------------------------- 1 | use std::future::Future; 2 | use std::sync::Arc; 3 | 4 | use wasm_pkg_common::{ 5 | digest::ContentDigest, 6 | package::{PackageRef, Version}, 7 | Error, 8 | }; 9 | 10 | use crate::{Client, ContentStream, Release, VersionInfo}; 11 | 12 | mod file; 13 | 14 | pub use file::FileCache; 15 | 16 | /// A trait for a cache of data. 17 | pub trait Cache { 18 | /// Puts the data with the given hash into the cache 19 | fn put_data( 20 | &self, 21 | digest: ContentDigest, 22 | data: ContentStream, 23 | ) -> impl Future> + Send; 24 | 25 | /// Gets the data with the given hash from the cache. Returns None if the data is not in the cache. 26 | fn get_data( 27 | &self, 28 | digest: &ContentDigest, 29 | ) -> impl Future, Error>> + Send; 30 | 31 | /// Puts the release data into the cache. 32 | fn put_release( 33 | &self, 34 | package: &PackageRef, 35 | release: &Release, 36 | ) -> impl Future> + Send; 37 | 38 | /// Gets the release data from the cache. Returns None if the data is not in the cache. 39 | fn get_release( 40 | &self, 41 | package: &PackageRef, 42 | version: &Version, 43 | ) -> impl Future, Error>> + Send; 44 | } 45 | 46 | /// A client that caches response data using the given cache implementation. Can be used without an 47 | /// underlying client to be used as a read-only cache. 48 | pub struct CachingClient { 49 | client: Option, 50 | cache: Arc, 51 | } 52 | 53 | impl Clone for CachingClient { 54 | fn clone(&self) -> Self { 55 | Self { 56 | client: self.client.clone(), 57 | cache: self.cache.clone(), 58 | } 59 | } 60 | } 61 | 62 | impl CachingClient { 63 | /// Creates a new caching client from the given client and cache implementation. If no client is 64 | /// given, the client will be in offline or read-only mode, meaning it will only be able to return 65 | /// things that are already in the cache. 66 | pub fn new(client: Option, cache: T) -> Self { 67 | Self { 68 | client, 69 | cache: Arc::new(cache), 70 | } 71 | } 72 | 73 | /// Returns whether or not the client is in read-only mode. 74 | pub fn is_readonly(&self) -> bool { 75 | self.client.is_none() 76 | } 77 | 78 | /// Returns a list of all package [`VersionInfo`]s available for the given package. This will 79 | /// always fail if no client was provided. 80 | pub async fn list_all_versions(&self, package: &PackageRef) -> Result, Error> { 81 | let client = self.client()?; 82 | client.list_all_versions(package).await 83 | } 84 | 85 | /// Returns a [`Release`] for the given package version. 86 | pub async fn get_release( 87 | &self, 88 | package: &PackageRef, 89 | version: &Version, 90 | ) -> Result { 91 | if let Some(data) = self.cache.get_release(package, version).await? { 92 | return Ok(data); 93 | } 94 | 95 | let client = self.client()?; 96 | let release = client.get_release(package, version).await?; 97 | self.cache.put_release(package, &release).await?; 98 | Ok(release) 99 | } 100 | 101 | /// Returns a [`ContentStream`] of content chunks. If the data is in the cache, it will be returned, 102 | /// otherwise it will be fetched from an upstream registry and then cached. This is the same as 103 | /// [`Client::stream_content`] but named differently to avoid confusion when trying to use this 104 | /// as a normal [`Client`]. 105 | pub async fn get_content( 106 | &self, 107 | package: &PackageRef, 108 | release: &Release, 109 | ) -> Result { 110 | if let Some(data) = self.cache.get_data(&release.content_digest).await? { 111 | return Ok(data); 112 | } 113 | 114 | let client = self.client()?; 115 | let stream = client.stream_content(package, release).await?; 116 | self.cache 117 | .put_data(release.content_digest.clone(), stream) 118 | .await?; 119 | 120 | self.cache 121 | .get_data(&release.content_digest) 122 | .await? 123 | .ok_or_else(|| { 124 | Error::CacheError(anyhow::anyhow!( 125 | "Cached data was deleted after putting the data in cache" 126 | )) 127 | }) 128 | } 129 | 130 | /// Returns a reference to the underlying client. Returns an error if the client is in read-only 131 | /// mode. 132 | /// 133 | /// Please note that using the client directly will bypass the cache. 134 | pub fn client(&self) -> Result<&Client, Error> { 135 | self.client 136 | .as_ref() 137 | .ok_or_else(|| Error::CacheError(anyhow::anyhow!("Client is in read only mode"))) 138 | } 139 | 140 | /// Consumes the caching client and returns the underlying client. Returns an error if the 141 | /// client is in read-only mode. 142 | pub fn into_client(self) -> Result { 143 | self.client 144 | .ok_or_else(|| Error::CacheError(anyhow::anyhow!("Client is in read only mode"))) 145 | } 146 | } 147 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/lib.rs: -------------------------------------------------------------------------------- 1 | //! Wasm Package Client 2 | //! 3 | //! [`Client`] implements a unified interface for loading package content from 4 | //! multiple kinds of package registries. 5 | //! 6 | //! # Example 7 | //! 8 | //! ```no_run 9 | //! # async fn example() -> anyhow::Result<()> { 10 | //! // Initialize client from global configuration. 11 | //! let mut client = wasm_pkg_client::Client::with_global_defaults().await?; 12 | //! 13 | //! // Get a specific package release version. 14 | //! let pkg = "example:pkg".parse()?; 15 | //! let version = "1.0.0".parse()?; 16 | //! let release = client.get_release(&pkg, &version).await?; 17 | //! 18 | //! // Stream release content to a file. 19 | //! let mut stream = client.stream_content(&pkg, &release).await?; 20 | //! let mut file = tokio::fs::File::create("output.wasm").await?; 21 | //! use futures_util::TryStreamExt; 22 | //! use tokio::io::AsyncWriteExt; 23 | //! while let Some(chunk) = stream.try_next().await? { 24 | //! file.write_all(&chunk).await?; 25 | //! } 26 | //! # Ok(()) } 27 | //! ``` 28 | 29 | pub mod caching; 30 | mod loader; 31 | pub mod local; 32 | pub mod oci; 33 | mod publisher; 34 | mod release; 35 | pub mod warg; 36 | 37 | use std::path::Path; 38 | use std::sync::Arc; 39 | use std::{collections::HashMap, pin::Pin}; 40 | 41 | use anyhow::anyhow; 42 | use bytes::Bytes; 43 | use futures_util::Stream; 44 | use publisher::PackagePublisher; 45 | use tokio::io::AsyncSeekExt; 46 | use tokio::sync::RwLock; 47 | use tokio_util::io::SyncIoBridge; 48 | pub use wasm_pkg_common::{ 49 | config::{Config, CustomConfig, RegistryMapping}, 50 | digest::ContentDigest, 51 | metadata::RegistryMetadata, 52 | package::{PackageRef, Version}, 53 | registry::Registry, 54 | Error, 55 | }; 56 | use wit_component::DecodedWasm; 57 | 58 | use crate::{loader::PackageLoader, local::LocalBackend, oci::OciBackend, warg::WargBackend}; 59 | 60 | pub use release::{Release, VersionInfo}; 61 | 62 | /// An alias for a stream of content bytes 63 | pub type ContentStream = Pin> + Send + 'static>>; 64 | 65 | /// An alias for a PublishingSource (generally a file) 66 | pub type PublishingSource = Pin>; 67 | 68 | /// A supertrait combining tokio's AsyncRead and AsyncSeek. 69 | pub trait ReaderSeeker: tokio::io::AsyncRead + tokio::io::AsyncSeek {} 70 | impl ReaderSeeker for T where T: tokio::io::AsyncRead + tokio::io::AsyncSeek {} 71 | 72 | trait LoaderPublisher: PackageLoader + PackagePublisher {} 73 | 74 | impl LoaderPublisher for T where T: PackageLoader + PackagePublisher {} 75 | 76 | type RegistrySources = HashMap>; 77 | type InnerClient = Box; 78 | 79 | /// Additional options for publishing a package. 80 | #[derive(Clone, Debug, Default)] 81 | pub struct PublishOpts { 82 | /// Override the package name and version to publish with. 83 | pub package: Option<(PackageRef, Version)>, 84 | /// Override the registry to publish to. 85 | pub registry: Option, 86 | } 87 | 88 | /// A read-only registry client. 89 | #[derive(Clone)] 90 | pub struct Client { 91 | config: Arc, 92 | sources: Arc>, 93 | } 94 | 95 | impl Client { 96 | /// Returns a new client with the given [`Config`]. 97 | pub fn new(config: Config) -> Self { 98 | Self { 99 | config: Arc::new(config), 100 | sources: Default::default(), 101 | } 102 | } 103 | 104 | /// Returns a reference to the configuration this client was initialized with. 105 | pub fn config(&self) -> &Config { 106 | &self.config 107 | } 108 | 109 | /// Returns a new client configured from default global config. 110 | pub async fn with_global_defaults() -> Result { 111 | let config = Config::global_defaults().await?; 112 | Ok(Self::new(config)) 113 | } 114 | 115 | /// Returns a list of all package [`Version`]s available for the given package. 116 | pub async fn list_all_versions(&self, package: &PackageRef) -> Result, Error> { 117 | let source = self.resolve_source(package, None).await?; 118 | source.list_all_versions(package).await 119 | } 120 | 121 | /// Returns a [`Release`] for the given package version. 122 | pub async fn get_release( 123 | &self, 124 | package: &PackageRef, 125 | version: &Version, 126 | ) -> Result { 127 | let source = self.resolve_source(package, None).await?; 128 | source.get_release(package, version).await 129 | } 130 | 131 | /// Returns a [`ContentStream`] of content chunks. Contents are validated 132 | /// against the given [`Release::content_digest`]. 133 | pub async fn stream_content<'a>( 134 | &'a self, 135 | package: &'a PackageRef, 136 | release: &'a Release, 137 | ) -> Result { 138 | let source = self.resolve_source(package, None).await?; 139 | source.stream_content(package, release).await 140 | } 141 | 142 | /// Publishes the given file as a package release. The package name and version will be read 143 | /// from the component if not given as part of `additional_options`. Returns the package name 144 | /// and version of the published release. 145 | pub async fn publish_release_file( 146 | &self, 147 | file: impl AsRef, 148 | additional_options: PublishOpts, 149 | ) -> Result<(PackageRef, Version), Error> { 150 | let data = tokio::fs::OpenOptions::new().read(true).open(file).await?; 151 | 152 | self.publish_release_data(Box::pin(data), additional_options) 153 | .await 154 | } 155 | 156 | /// Publishes the given reader as a package release. TThe package name and version will be read 157 | /// from the component if not given as part of `additional_options`. Returns the package name 158 | /// and version of the published release. 159 | pub async fn publish_release_data( 160 | &self, 161 | data: PublishingSource, 162 | additional_options: PublishOpts, 163 | ) -> Result<(PackageRef, Version), Error> { 164 | let (data, package, version) = if let Some((p, v)) = additional_options.package { 165 | (data, p, v) 166 | } else { 167 | let data = SyncIoBridge::new(data); 168 | let (mut data, p, v) = tokio::task::spawn_blocking(|| resolve_package(data)) 169 | .await 170 | .map_err(|e| { 171 | crate::Error::IoError(std::io::Error::new( 172 | std::io::ErrorKind::Other, 173 | format!("Error when performing blocking IO: {e:?}"), 174 | )) 175 | })??; 176 | // We must rewind the reader because we read to the end to parse the component. 177 | data.rewind().await?; 178 | (data, p, v) 179 | }; 180 | let source = self 181 | .resolve_source(&package, additional_options.registry) 182 | .await?; 183 | source 184 | .publish(&package, &version, data) 185 | .await 186 | .map(|_| (package, version)) 187 | } 188 | 189 | async fn resolve_source( 190 | &self, 191 | package: &PackageRef, 192 | registry_override: Option, 193 | ) -> Result, Error> { 194 | let is_override = registry_override.is_some(); 195 | let registry = if let Some(registry) = registry_override { 196 | registry 197 | } else { 198 | self.config 199 | .resolve_registry(package) 200 | .ok_or_else(|| Error::NoRegistryForNamespace(package.namespace().clone()))? 201 | .to_owned() 202 | }; 203 | let has_key = { 204 | let sources = self.sources.read().await; 205 | sources.contains_key(®istry) 206 | }; 207 | if !has_key { 208 | let registry_config = self 209 | .config 210 | .registry_config(®istry) 211 | .cloned() 212 | .unwrap_or_default(); 213 | 214 | // Skip fetching metadata for "local" source 215 | let should_fetch_meta = registry_config.default_backend() != Some("local"); 216 | let maybe_metadata = self 217 | .config 218 | .package_registry_override(package) 219 | .and_then(|mapping| match mapping { 220 | RegistryMapping::Custom(custom) => Some(custom.metadata.clone()), 221 | _ => None, 222 | }) 223 | .or_else(|| { 224 | self.config 225 | .namespace_registry(package.namespace()) 226 | .and_then(|meta| { 227 | // If the overriden registry matches the registry we are trying to resolve, we 228 | // should use the metadata, otherwise we'll need to fetch the metadata from the 229 | // registry 230 | match (meta, is_override) { 231 | (RegistryMapping::Custom(custom), true) 232 | if custom.registry == registry => 233 | { 234 | Some(custom.metadata.clone()) 235 | } 236 | (RegistryMapping::Custom(custom), false) => { 237 | Some(custom.metadata.clone()) 238 | } 239 | _ => None, 240 | } 241 | }) 242 | }); 243 | 244 | let registry_meta = if let Some(meta) = maybe_metadata { 245 | meta 246 | } else if should_fetch_meta { 247 | RegistryMetadata::fetch_or_default(®istry).await 248 | } else { 249 | RegistryMetadata::default() 250 | }; 251 | 252 | // Resolve backend type 253 | let backend_type = match registry_config.default_backend() { 254 | // If the local config specifies a backend type, use it 255 | Some(backend_type) => Some(backend_type), 256 | None => { 257 | // If the registry metadata indicates a preferred protocol, use it 258 | let preferred_protocol = registry_meta.preferred_protocol(); 259 | // ...except registry metadata cannot force a local backend 260 | if preferred_protocol == Some("local") { 261 | return Err(Error::InvalidRegistryMetadata(anyhow!( 262 | "registry metadata with 'local' protocol not allowed" 263 | ))); 264 | } 265 | preferred_protocol 266 | } 267 | } 268 | // Otherwise use the default backend 269 | .unwrap_or("oci"); 270 | tracing::debug!(?backend_type, "Resolved backend type"); 271 | 272 | let source: InnerClient = match backend_type { 273 | "local" => Box::new(LocalBackend::new(registry_config)?), 274 | "oci" => Box::new(OciBackend::new( 275 | ®istry, 276 | ®istry_config, 277 | ®istry_meta, 278 | )?), 279 | "warg" => { 280 | Box::new(WargBackend::new(®istry, ®istry_config, ®istry_meta).await?) 281 | } 282 | other => { 283 | return Err(Error::InvalidConfig(anyhow!( 284 | "unknown backend type {other:?}" 285 | ))); 286 | } 287 | }; 288 | self.sources 289 | .write() 290 | .await 291 | .insert(registry.clone(), Arc::new(source)); 292 | } 293 | Ok(self.sources.read().await.get(®istry).unwrap().clone()) 294 | } 295 | } 296 | 297 | /// Resolves the package name and version from the given source. This takes a wrapped publishing 298 | /// source to it can do a blocking read with wit_component. It returns back the underlying 299 | /// PublishingSource but should be rewound to the beginning of the source 300 | fn resolve_package( 301 | mut data: SyncIoBridge, 302 | ) -> Result<(PublishingSource, PackageRef, Version), Error> { 303 | let (resolve, package_id) = 304 | match wit_component::decode_reader(&mut data).map_err(crate::Error::InvalidComponent)? { 305 | DecodedWasm::Component(resolve, world_id) => { 306 | let package_id = resolve 307 | .worlds 308 | .iter() 309 | .find_map(|(id, w)| if id == world_id { w.package } else { None }) 310 | .ok_or_else(|| { 311 | crate::Error::InvalidComponent(anyhow::anyhow!( 312 | "component world or package not found" 313 | )) 314 | })?; 315 | (resolve, package_id) 316 | } 317 | DecodedWasm::WitPackage(resolve, package_id) => (resolve, package_id), 318 | }; 319 | let (package, version) = resolve 320 | .package_names 321 | .into_iter() 322 | .find_map(|(pkg, id)| { 323 | // SAFETY: We just parsed this from wit and should be able to unwrap. If it 324 | // isn't a valid identifier, something else is majorly wrong 325 | (id == package_id).then(|| { 326 | ( 327 | PackageRef::new( 328 | pkg.namespace.try_into().unwrap(), 329 | pkg.name.try_into().unwrap(), 330 | ), 331 | pkg.version, 332 | ) 333 | }) 334 | }) 335 | .ok_or_else(|| { 336 | crate::Error::InvalidComponent(anyhow::anyhow!("component package not found")) 337 | })?; 338 | 339 | let version = version.ok_or_else(|| { 340 | crate::Error::InvalidComponent(anyhow::anyhow!("component package version not found")) 341 | })?; 342 | Ok((data.into_inner(), package, version)) 343 | } 344 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/loader.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use futures_util::StreamExt; 3 | use wasm_pkg_common::{ 4 | package::{PackageRef, Version}, 5 | Error, 6 | }; 7 | 8 | use crate::{ 9 | release::{Release, VersionInfo}, 10 | ContentStream, 11 | }; 12 | 13 | #[async_trait] 14 | pub trait PackageLoader: Send { 15 | async fn list_all_versions(&self, package: &PackageRef) -> Result, Error>; 16 | 17 | async fn get_release(&self, package: &PackageRef, version: &Version) -> Result; 18 | 19 | async fn stream_content_unvalidated( 20 | &self, 21 | package: &PackageRef, 22 | release: &Release, 23 | ) -> Result; 24 | 25 | async fn stream_content( 26 | &self, 27 | package: &PackageRef, 28 | release: &Release, 29 | ) -> Result { 30 | let stream = self.stream_content_unvalidated(package, release).await?; 31 | Ok(release.content_digest.validating_stream(stream).boxed()) 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/local.rs: -------------------------------------------------------------------------------- 1 | //! Local filesystem-based package backend. 2 | //! 3 | //! Each package release is a file: `///.wasm` 4 | 5 | use std::path::PathBuf; 6 | 7 | use anyhow::anyhow; 8 | use async_trait::async_trait; 9 | use futures_util::{StreamExt, TryStreamExt}; 10 | use serde::Deserialize; 11 | use tokio_util::io::ReaderStream; 12 | use wasm_pkg_common::{ 13 | config::RegistryConfig, 14 | digest::ContentDigest, 15 | package::{PackageRef, Version}, 16 | Error, 17 | }; 18 | 19 | use crate::{ 20 | loader::PackageLoader, 21 | publisher::PackagePublisher, 22 | release::{Release, VersionInfo}, 23 | ContentStream, PublishingSource, 24 | }; 25 | 26 | #[derive(Clone, Debug, Deserialize)] 27 | pub struct LocalConfig { 28 | pub root: PathBuf, 29 | } 30 | 31 | pub(crate) struct LocalBackend { 32 | root: PathBuf, 33 | } 34 | 35 | impl LocalBackend { 36 | pub fn new(registry_config: RegistryConfig) -> Result { 37 | let config = registry_config 38 | .backend_config::("local")? 39 | .ok_or_else(|| { 40 | Error::InvalidConfig(anyhow!("'local' backend requires configuration")) 41 | })?; 42 | Ok(Self { root: config.root }) 43 | } 44 | 45 | fn package_dir(&self, package: &PackageRef) -> PathBuf { 46 | self.root 47 | .join(package.namespace().as_ref()) 48 | .join(package.name().as_ref()) 49 | } 50 | 51 | fn version_path(&self, package: &PackageRef, version: &Version) -> PathBuf { 52 | self.package_dir(package).join(format!("{version}.wasm")) 53 | } 54 | } 55 | 56 | #[async_trait] 57 | impl PackageLoader for LocalBackend { 58 | async fn list_all_versions(&self, package: &PackageRef) -> Result, Error> { 59 | let mut versions = vec![]; 60 | let package_dir = self.package_dir(package); 61 | tracing::debug!(?package_dir, "Reading versions from path"); 62 | let mut entries = tokio::fs::read_dir(package_dir).await?; 63 | while let Some(entry) = entries.next_entry().await? { 64 | let path = entry.path(); 65 | if path.extension() != Some("wasm".as_ref()) { 66 | continue; 67 | } 68 | let Some(version) = path 69 | .file_stem() 70 | .unwrap() 71 | .to_str() 72 | .and_then(|stem| Version::parse(stem).ok()) 73 | else { 74 | tracing::warn!("invalid package file name at {path:?}"); 75 | continue; 76 | }; 77 | versions.push(VersionInfo { 78 | version, 79 | yanked: false, 80 | }); 81 | } 82 | Ok(versions) 83 | } 84 | 85 | async fn get_release(&self, package: &PackageRef, version: &Version) -> Result { 86 | let path = self.version_path(package, version); 87 | tracing::debug!(path = %path.display(), "Reading content from path"); 88 | let content_digest = ContentDigest::sha256_from_file(path).await?; 89 | Ok(Release { 90 | version: version.clone(), 91 | content_digest, 92 | }) 93 | } 94 | 95 | async fn stream_content_unvalidated( 96 | &self, 97 | package: &PackageRef, 98 | content: &Release, 99 | ) -> Result { 100 | let path = self.version_path(package, &content.version); 101 | tracing::debug!("Streaming content from {path:?}"); 102 | let file = tokio::fs::File::open(path).await?; 103 | Ok(ReaderStream::new(file).map_err(Into::into).boxed()) 104 | } 105 | } 106 | 107 | #[async_trait::async_trait] 108 | impl PackagePublisher for LocalBackend { 109 | async fn publish( 110 | &self, 111 | package: &PackageRef, 112 | version: &Version, 113 | mut data: PublishingSource, 114 | ) -> Result<(), Error> { 115 | let package_dir = self.package_dir(package); 116 | // Ensure the package directory exists. 117 | tokio::fs::create_dir_all(package_dir).await?; 118 | let path = self.version_path(package, version); 119 | let mut out = tokio::fs::File::create(path).await?; 120 | tokio::io::copy(&mut data, &mut out) 121 | .await 122 | .map_err(Error::IoError) 123 | .map(|_| ()) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/oci/config.rs: -------------------------------------------------------------------------------- 1 | use anyhow::Context; 2 | use base64::{ 3 | engine::{DecodePaddingMode, GeneralPurpose, GeneralPurposeConfig}, 4 | Engine, 5 | }; 6 | use oci_client::client::ClientConfig; 7 | use secrecy::{ExposeSecret, SecretString}; 8 | use serde::{Deserialize, Serialize, Serializer}; 9 | use wasm_pkg_common::{config::RegistryConfig, Error}; 10 | 11 | /// Registry configuration for OCI backends. 12 | /// 13 | /// See: [`RegistryConfig::backend_config`] 14 | #[derive(Default, Serialize)] 15 | #[serde(into = "OciRegistryConfigToml")] 16 | pub struct OciRegistryConfig { 17 | pub client_config: ClientConfig, 18 | pub credentials: Option, 19 | } 20 | 21 | impl Clone for OciRegistryConfig { 22 | fn clone(&self) -> Self { 23 | let client_config = ClientConfig { 24 | protocol: self.client_config.protocol.clone(), 25 | extra_root_certificates: self.client_config.extra_root_certificates.clone(), 26 | platform_resolver: None, 27 | https_proxy: self.client_config.https_proxy.clone(), 28 | no_proxy: self.client_config.no_proxy.clone(), 29 | ..self.client_config 30 | }; 31 | Self { 32 | client_config, 33 | credentials: self.credentials.clone(), 34 | } 35 | } 36 | } 37 | 38 | impl std::fmt::Debug for OciRegistryConfig { 39 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 40 | f.debug_struct("OciConfig") 41 | .field("client_config", &"...") 42 | .field("credentials", &self.credentials) 43 | .finish() 44 | } 45 | } 46 | 47 | impl TryFrom<&RegistryConfig> for OciRegistryConfig { 48 | type Error = Error; 49 | 50 | fn try_from(registry_config: &RegistryConfig) -> Result { 51 | let OciRegistryConfigToml { auth, protocol } = 52 | registry_config.backend_config("oci")?.unwrap_or_default(); 53 | let mut client_config = ClientConfig::default(); 54 | if let Some(protocol) = protocol { 55 | client_config.protocol = oci_client_protocol(&protocol)?; 56 | }; 57 | let credentials = auth 58 | .map(TryInto::try_into) 59 | .transpose() 60 | .map_err(Error::InvalidConfig)?; 61 | Ok(Self { 62 | client_config, 63 | credentials, 64 | }) 65 | } 66 | } 67 | 68 | #[derive(Default, Deserialize, Serialize)] 69 | struct OciRegistryConfigToml { 70 | auth: Option, 71 | protocol: Option, 72 | } 73 | 74 | impl From for OciRegistryConfigToml { 75 | fn from(value: OciRegistryConfig) -> Self { 76 | OciRegistryConfigToml { 77 | auth: value.credentials.map(|c| TomlAuth::UsernamePassword { 78 | username: c.username, 79 | password: c.password, 80 | }), 81 | protocol: Some(oci_protocol_string(&value.client_config.protocol)), 82 | } 83 | } 84 | } 85 | 86 | #[derive(Deserialize, Serialize)] 87 | #[serde(untagged)] 88 | #[serde(deny_unknown_fields)] 89 | enum TomlAuth { 90 | #[serde(serialize_with = "serialize_secret")] 91 | Base64(SecretString), 92 | UsernamePassword { 93 | username: String, 94 | #[serde(serialize_with = "serialize_secret")] 95 | password: SecretString, 96 | }, 97 | } 98 | 99 | #[derive(Clone, Debug)] 100 | pub struct BasicCredentials { 101 | pub username: String, 102 | pub password: SecretString, 103 | } 104 | 105 | const OCI_AUTH_BASE64: GeneralPurpose = GeneralPurpose::new( 106 | &base64::alphabet::STANDARD, 107 | GeneralPurposeConfig::new().with_decode_padding_mode(DecodePaddingMode::Indifferent), 108 | ); 109 | 110 | impl TryFrom for BasicCredentials { 111 | type Error = anyhow::Error; 112 | 113 | fn try_from(value: TomlAuth) -> Result { 114 | match value { 115 | TomlAuth::Base64(b64) => { 116 | fn decode_b64_creds(b64: &str) -> anyhow::Result { 117 | let bs = OCI_AUTH_BASE64.decode(b64)?; 118 | let s = String::from_utf8(bs)?; 119 | let (username, password) = s 120 | .split_once(':') 121 | .context("expected : but no ':' found")?; 122 | Ok(BasicCredentials { 123 | username: username.into(), 124 | password: password.to_string().into(), 125 | }) 126 | } 127 | decode_b64_creds(b64.expose_secret()).context("invalid base64-encoded creds") 128 | } 129 | TomlAuth::UsernamePassword { username, password } => { 130 | Ok(BasicCredentials { username, password }) 131 | } 132 | } 133 | } 134 | } 135 | 136 | fn oci_client_protocol(text: &str) -> Result { 137 | match text { 138 | "http" => Ok(oci_client::client::ClientProtocol::Http), 139 | "https" => Ok(oci_client::client::ClientProtocol::Https), 140 | _ => Err(Error::InvalidConfig(anyhow::anyhow!( 141 | "Unknown OCI protocol {text:?}" 142 | ))), 143 | } 144 | } 145 | 146 | fn oci_protocol_string(protocol: &oci_client::client::ClientProtocol) -> String { 147 | match protocol { 148 | oci_client::client::ClientProtocol::Http => "http".into(), 149 | oci_client::client::ClientProtocol::Https => "https".into(), 150 | // Default to https if not specified 151 | _ => "https".into(), 152 | } 153 | } 154 | 155 | fn serialize_secret( 156 | secret: &SecretString, 157 | serializer: S, 158 | ) -> Result { 159 | secret.expose_secret().serialize(serializer) 160 | } 161 | 162 | #[cfg(test)] 163 | mod tests { 164 | use wasm_pkg_common::config::RegistryMapping; 165 | 166 | use crate::oci::OciRegistryMetadata; 167 | 168 | use super::*; 169 | 170 | #[test] 171 | fn smoke_test() { 172 | let toml_config = r#" 173 | [registry."example.com"] 174 | type = "oci" 175 | [registry."example.com".oci] 176 | auth = { username = "open", password = "sesame" } 177 | protocol = "http" 178 | 179 | [registry."wasi.dev"] 180 | type = "oci" 181 | [registry."wasi.dev".oci] 182 | auth = "cGluZzpwb25n" 183 | "#; 184 | let cfg = wasm_pkg_common::config::Config::from_toml(toml_config).unwrap(); 185 | 186 | let oci_config: OciRegistryConfig = cfg 187 | .registry_config(&"example.com".parse().unwrap()) 188 | .unwrap() 189 | .try_into() 190 | .unwrap(); 191 | let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap(); 192 | assert_eq!(username, "open"); 193 | assert_eq!(password.expose_secret(), "sesame"); 194 | assert_eq!( 195 | oci_client::client::ClientProtocol::Http, 196 | oci_config.client_config.protocol 197 | ); 198 | 199 | let oci_config: OciRegistryConfig = cfg 200 | .registry_config(&"wasi.dev".parse().unwrap()) 201 | .unwrap() 202 | .try_into() 203 | .unwrap(); 204 | let BasicCredentials { username, password } = oci_config.credentials.as_ref().unwrap(); 205 | assert_eq!(username, "ping"); 206 | assert_eq!(password.expose_secret(), "pong"); 207 | } 208 | 209 | #[test] 210 | fn test_roundtrip() { 211 | let config = OciRegistryConfig { 212 | client_config: oci_client::client::ClientConfig { 213 | protocol: oci_client::client::ClientProtocol::Http, 214 | ..Default::default() 215 | }, 216 | credentials: Some(BasicCredentials { 217 | username: "open".into(), 218 | password: SecretString::new("sesame".into()), 219 | }), 220 | }; 221 | 222 | // Set the data and then try to load it back 223 | let mut conf = crate::Config::empty(); 224 | 225 | let registry: crate::Registry = "example.com:8080".parse().unwrap(); 226 | let reg_conf = conf.get_or_insert_registry_config_mut(®istry); 227 | reg_conf 228 | .set_backend_config("oci", &config) 229 | .expect("Unable to set config"); 230 | 231 | let reg_conf = conf.registry_config(®istry).unwrap(); 232 | 233 | let roundtripped = OciRegistryConfig::try_from(reg_conf).expect("Unable to load config"); 234 | assert_eq!( 235 | roundtripped.client_config.protocol, config.client_config.protocol, 236 | "Home url should be set to the right value" 237 | ); 238 | let creds = config.credentials.unwrap(); 239 | let roundtripped_creds = roundtripped.credentials.expect("Should have creds"); 240 | assert_eq!( 241 | creds.username, roundtripped_creds.username, 242 | "Username should be set to the right value" 243 | ); 244 | assert_eq!( 245 | creds.password.expose_secret(), 246 | roundtripped_creds.password.expose_secret(), 247 | "Password should be set to the right value" 248 | ); 249 | } 250 | 251 | #[test] 252 | fn test_custom_namespace_config() { 253 | let toml_config = toml::toml! { 254 | [namespace_registries] 255 | test = { registry = "localhost:1234", metadata = { preferredProtocol = "oci", "oci" = { registry = "ghcr.io", namespacePrefix = "webassembly/" } } } 256 | }; 257 | 258 | let cfg = wasm_pkg_common::config::Config::from_toml(&toml_config.to_string()) 259 | .expect("Should be able to load config"); 260 | 261 | let ns_config = cfg 262 | .namespace_registry(&"test".parse().unwrap()) 263 | .expect("Should have a namespace config"); 264 | let custom = match ns_config { 265 | RegistryMapping::Custom(c) => c, 266 | _ => panic!("Should have a custom namespace config"), 267 | }; 268 | let map: OciRegistryMetadata = custom 269 | .metadata 270 | .protocol_config("oci") 271 | .expect("Should be able to deserialize config") 272 | .expect("protocol config should be present"); 273 | assert_eq!(map.namespace_prefix, Some("webassembly/".to_string())); 274 | assert_eq!(map.registry, Some("ghcr.io".to_string())); 275 | } 276 | } 277 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/oci/loader.rs: -------------------------------------------------------------------------------- 1 | use async_trait::async_trait; 2 | use futures_util::{StreamExt, TryStreamExt}; 3 | use oci_client::{manifest::OciDescriptor, RegistryOperation}; 4 | use warg_protocol::Version; 5 | use wasm_pkg_common::{package::PackageRef, Error}; 6 | 7 | use crate::{ 8 | loader::PackageLoader, 9 | release::{Release, VersionInfo}, 10 | ContentStream, 11 | }; 12 | 13 | use super::{oci_registry_error, OciBackend}; 14 | 15 | #[async_trait] 16 | impl PackageLoader for OciBackend { 17 | async fn list_all_versions(&self, package: &PackageRef) -> Result, Error> { 18 | let reference = self.make_reference(package, None); 19 | 20 | tracing::debug!(?reference, "Listing tags for OCI reference"); 21 | let auth = self.auth(&reference, RegistryOperation::Pull).await?; 22 | let resp = self 23 | .client 24 | .list_tags(&reference, &auth, None, None) 25 | .await 26 | .map_err(oci_registry_error)?; 27 | tracing::trace!(response = ?resp, "List tags response"); 28 | 29 | // Return only tags that parse as valid semver versions. 30 | let versions = resp 31 | .tags 32 | .iter() 33 | .flat_map(|tag| match Version::parse(tag) { 34 | Ok(version) => Some(VersionInfo { 35 | version, 36 | yanked: false, 37 | }), 38 | Err(err) => { 39 | tracing::warn!(?tag, error = ?err, "Ignoring invalid version tag"); 40 | None 41 | } 42 | }) 43 | .collect(); 44 | Ok(versions) 45 | } 46 | 47 | async fn get_release(&self, package: &PackageRef, version: &Version) -> Result { 48 | let reference = self.make_reference(package, Some(version)); 49 | 50 | tracing::debug!(?reference, "Fetching image manifest for OCI reference"); 51 | let auth = self.auth(&reference, RegistryOperation::Pull).await?; 52 | let (manifest, _config, _digest) = self 53 | .client 54 | .pull_manifest_and_config(&reference, &auth) 55 | .await 56 | .map_err(Error::RegistryError)?; 57 | tracing::trace!(?manifest, "Got manifest"); 58 | 59 | let version = version.to_owned(); 60 | let content_digest = manifest 61 | .layers 62 | .into_iter() 63 | .next() 64 | .ok_or_else(|| { 65 | Error::InvalidPackageManifest("Returned manifest had no layers".to_string()) 66 | })? 67 | .digest 68 | .parse()?; 69 | Ok(Release { 70 | version, 71 | content_digest, 72 | }) 73 | } 74 | 75 | async fn stream_content_unvalidated( 76 | &self, 77 | package: &PackageRef, 78 | release: &Release, 79 | ) -> Result { 80 | let reference = self.make_reference(package, None); 81 | let descriptor = OciDescriptor { 82 | digest: release.content_digest.to_string(), 83 | ..Default::default() 84 | }; 85 | self.auth(&reference, RegistryOperation::Pull).await?; 86 | let stream = self 87 | .client 88 | .pull_blob_stream(&reference, &descriptor) 89 | .await 90 | .map_err(oci_registry_error)?; 91 | Ok(stream.map_err(Into::into).boxed()) 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/oci/mod.rs: -------------------------------------------------------------------------------- 1 | //! OCI package client. 2 | //! 3 | //! This follows the CNCF TAG Runtime guidance for [Wasm OCI Artifacts][1]. 4 | //! 5 | //! [1]: https://tag-runtime.cncf.io/wgs/wasm/deliverables/wasm-oci-artifact/ 6 | 7 | mod config; 8 | mod loader; 9 | mod publisher; 10 | 11 | use docker_credential::{CredentialRetrievalError, DockerCredential}; 12 | use oci_client::{ 13 | errors::OciDistributionError, secrets::RegistryAuth, Reference, RegistryOperation, 14 | }; 15 | use secrecy::ExposeSecret; 16 | use serde::Deserialize; 17 | use tokio::sync::OnceCell; 18 | use wasm_pkg_common::{ 19 | config::RegistryConfig, 20 | metadata::RegistryMetadata, 21 | package::{PackageRef, Version}, 22 | registry::Registry, 23 | Error, 24 | }; 25 | 26 | /// Re-exported for convenience. 27 | pub use oci_client::client; 28 | 29 | pub use config::{BasicCredentials, OciRegistryConfig}; 30 | 31 | #[derive(Default, Deserialize)] 32 | #[serde(rename_all = "camelCase")] 33 | struct OciRegistryMetadata { 34 | registry: Option, 35 | namespace_prefix: Option, 36 | } 37 | 38 | pub(crate) struct OciBackend { 39 | client: oci_wasm::WasmClient, 40 | oci_registry: String, 41 | namespace_prefix: Option, 42 | credentials: Option, 43 | registry_auth: OnceCell, 44 | } 45 | 46 | impl OciBackend { 47 | pub fn new( 48 | registry: &Registry, 49 | registry_config: &RegistryConfig, 50 | registry_meta: &RegistryMetadata, 51 | ) -> Result { 52 | let OciRegistryConfig { 53 | client_config, 54 | credentials, 55 | } = registry_config.try_into()?; 56 | let client = oci_client::Client::new(client_config); 57 | let client = oci_wasm::WasmClient::new(client); 58 | 59 | let oci_meta = registry_meta 60 | .protocol_config::("oci")? 61 | .unwrap_or_default(); 62 | let oci_registry = oci_meta.registry.unwrap_or_else(|| registry.to_string()); 63 | 64 | Ok(Self { 65 | client, 66 | oci_registry, 67 | namespace_prefix: oci_meta.namespace_prefix, 68 | credentials, 69 | registry_auth: OnceCell::new(), 70 | }) 71 | } 72 | 73 | pub(crate) async fn auth( 74 | &self, 75 | reference: &Reference, 76 | operation: RegistryOperation, 77 | ) -> Result { 78 | self.registry_auth 79 | .get_or_try_init(|| async { 80 | let mut auth = self.get_credentials()?; 81 | // Preflight auth to check for validity; this isn't wasted 82 | // effort because the oci_client::Client caches it 83 | use oci_client::errors::OciDistributionError::AuthenticationFailure; 84 | match self.client.auth(reference, &auth, operation).await { 85 | Ok(_) => (), 86 | Err(err @ AuthenticationFailure(_)) if auth != RegistryAuth::Anonymous => { 87 | // The failed credentials might not even be required for this image; retry anonymously 88 | if self 89 | .client 90 | .auth(reference, &RegistryAuth::Anonymous, operation) 91 | .await 92 | .is_ok() 93 | { 94 | auth = RegistryAuth::Anonymous; 95 | } else { 96 | return Err(oci_registry_error(err)); 97 | } 98 | } 99 | Err(err) => return Err(oci_registry_error(err)), 100 | } 101 | Ok(auth) 102 | }) 103 | .await 104 | .cloned() 105 | } 106 | 107 | pub(crate) fn get_credentials(&self) -> Result { 108 | if let Some(BasicCredentials { username, password }) = &self.credentials { 109 | return Ok(RegistryAuth::Basic( 110 | username.clone(), 111 | password.expose_secret().clone(), 112 | )); 113 | } 114 | 115 | let server_url = format!("https://{}", self.oci_registry); 116 | match docker_credential::get_credential(&server_url) { 117 | Ok(DockerCredential::UsernamePassword(username, password)) => { 118 | return Ok(RegistryAuth::Basic(username, password)); 119 | } 120 | Ok(DockerCredential::IdentityToken(_)) => { 121 | return Err(Error::CredentialError(anyhow::anyhow!( 122 | "identity tokens not supported" 123 | ))); 124 | } 125 | Err(err) => { 126 | if matches!( 127 | err, 128 | CredentialRetrievalError::ConfigNotFound 129 | | CredentialRetrievalError::ConfigReadError 130 | | CredentialRetrievalError::NoCredentialConfigured 131 | | CredentialRetrievalError::HelperFailure { .. } 132 | ) { 133 | tracing::debug!("Failed to look up OCI credentials: {err}"); 134 | } else { 135 | tracing::warn!("Failed to look up OCI credentials: {err}"); 136 | }; 137 | } 138 | } 139 | 140 | Ok(RegistryAuth::Anonymous) 141 | } 142 | 143 | pub(crate) fn make_reference( 144 | &self, 145 | package: &PackageRef, 146 | version: Option<&Version>, 147 | ) -> Reference { 148 | let repository = format!( 149 | "{}{}/{}", 150 | self.namespace_prefix.as_deref().unwrap_or_default(), 151 | package.namespace(), 152 | package.name() 153 | ); 154 | let tag = version 155 | .map(|ver| ver.to_string()) 156 | .unwrap_or_else(|| "latest".into()); 157 | Reference::with_tag(self.oci_registry.clone(), repository, tag) 158 | } 159 | } 160 | 161 | pub(crate) fn oci_registry_error(err: OciDistributionError) -> Error { 162 | match err { 163 | // Technically this could be a missing version too, but there really isn't a way to find out 164 | OciDistributionError::ImageManifestNotFoundError(_) => Error::PackageNotFound, 165 | _ => Error::RegistryError(err.into()), 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/oci/publisher.rs: -------------------------------------------------------------------------------- 1 | use std::collections::BTreeMap; 2 | 3 | use oci_client::{Reference, RegistryOperation}; 4 | use tokio::io::AsyncReadExt; 5 | 6 | use crate::publisher::PackagePublisher; 7 | use crate::{PackageRef, PublishingSource, Version}; 8 | 9 | use super::OciBackend; 10 | 11 | #[async_trait::async_trait] 12 | impl PackagePublisher for OciBackend { 13 | async fn publish( 14 | &self, 15 | package: &PackageRef, 16 | version: &Version, 17 | mut data: PublishingSource, 18 | ) -> Result<(), crate::Error> { 19 | // NOTE(thomastaylor312): oci-client doesn't support publishing from a stream or reader, so 20 | // we have to read all the data in for now. Once we can address that upstream, we'll be able 21 | // to remove this and use the stream directly. 22 | let mut buf = Vec::new(); 23 | data.read_to_end(&mut buf).await?; 24 | let payload = wasm_metadata::Payload::from_binary(&buf).map_err(|e| { 25 | crate::Error::InvalidComponent(anyhow::anyhow!("Unable to parse WASM: {e}")) 26 | })?; 27 | let meta = payload.metadata(); 28 | let (config, layer) = oci_wasm::WasmConfig::from_raw_component(buf, None) 29 | .map_err(crate::Error::InvalidComponent)?; 30 | let mut annotations = BTreeMap::from_iter([( 31 | "org.opencontainers.image.version".to_string(), 32 | version.to_string(), 33 | )]); 34 | if let Some(desc) = &meta.description { 35 | annotations.insert( 36 | "org.opencontainers.image.description".to_string(), 37 | desc.to_string(), 38 | ); 39 | } 40 | if let Some(licenses) = &meta.licenses { 41 | annotations.insert( 42 | "org.opencontainers.image.licenses".to_string(), 43 | licenses.to_string(), 44 | ); 45 | } 46 | if let Some(source) = &meta.source { 47 | annotations.insert( 48 | "org.opencontainers.image.source".to_string(), 49 | source.to_string(), 50 | ); 51 | } 52 | if let Some(homepage) = &meta.homepage { 53 | annotations.insert( 54 | "org.opencontainers.image.url".to_string(), 55 | homepage.to_string(), 56 | ); 57 | } 58 | if let Some(authors) = &meta.authors { 59 | annotations.insert( 60 | "org.opencontainers.image.authors".to_string(), 61 | authors.to_string(), 62 | ); 63 | } 64 | 65 | let reference: Reference = self.make_reference(package, Some(version)); 66 | let auth = self.auth(&reference, RegistryOperation::Push).await?; 67 | self.client 68 | .push(&reference, &auth, layer, config, Some(annotations)) 69 | .await 70 | .map_err(crate::Error::RegistryError)?; 71 | Ok(()) 72 | } 73 | } 74 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/publisher.rs: -------------------------------------------------------------------------------- 1 | use crate::{PackageRef, PublishingSource, Version}; 2 | 3 | #[async_trait::async_trait] 4 | pub trait PackagePublisher: Send + Sync { 5 | /// Publishes the data to the registry. The given data should be a valid wasm component and can 6 | /// be anything that implements [`AsyncRead`](tokio::io::AsyncRead) and 7 | /// [`AsyncSeek`](tokio::io::AsyncSeek). 8 | async fn publish( 9 | &self, 10 | package: &PackageRef, 11 | version: &Version, 12 | data: PublishingSource, 13 | ) -> Result<(), crate::Error>; 14 | } 15 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/release.rs: -------------------------------------------------------------------------------- 1 | use std::cmp::Ordering; 2 | 3 | use wasm_pkg_common::{digest::ContentDigest, package::Version}; 4 | 5 | /// Package release details. 6 | /// 7 | /// Returned by [`crate::Client::get_release`] and passed to 8 | /// [`crate::Client::stream_content`]. 9 | #[derive(Clone, Debug)] 10 | pub struct Release { 11 | pub version: Version, 12 | pub content_digest: ContentDigest, 13 | } 14 | 15 | #[derive(Clone, Debug, Eq)] 16 | pub struct VersionInfo { 17 | pub version: Version, 18 | pub yanked: bool, 19 | } 20 | 21 | impl Ord for VersionInfo { 22 | fn cmp(&self, other: &Self) -> Ordering { 23 | self.version.cmp(&other.version) 24 | } 25 | } 26 | 27 | impl PartialOrd for VersionInfo { 28 | fn partial_cmp(&self, other: &Self) -> Option { 29 | Some(self.version.cmp(&other.version)) 30 | } 31 | } 32 | 33 | impl PartialEq for VersionInfo { 34 | fn eq(&self, other: &Self) -> bool { 35 | self.version == other.version 36 | } 37 | } 38 | 39 | impl std::fmt::Display for VersionInfo { 40 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 41 | write!(f, "{version}", version = self.version) 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/warg/config.rs: -------------------------------------------------------------------------------- 1 | use std::{fmt::Debug, path::PathBuf, sync::Arc}; 2 | 3 | use secrecy::{ExposeSecret, SecretString}; 4 | use serde::{Deserialize, Serialize, Serializer}; 5 | use warg_crypto::signing::PrivateKey; 6 | use wasm_pkg_common::{config::RegistryConfig, Error}; 7 | 8 | /// Registry configuration for Warg backends. 9 | /// 10 | /// See: [`RegistryConfig::backend_config`] 11 | #[derive(Clone, Default, Serialize)] 12 | #[serde(into = "WargRegistryConfigToml")] 13 | pub struct WargRegistryConfig { 14 | /// The configuration for the Warg client. 15 | pub client_config: warg_client::Config, 16 | /// The authentication token for the Warg registry. 17 | pub auth_token: Option, 18 | /// A signing key to use for publishing packages. 19 | // NOTE(thomastaylor312): This couldn't be wrapped in a secret because the outer type doesn't 20 | // implement Zeroize. However, the inner type is zeroized. 21 | pub signing_key: Option>, 22 | /// The path to the Warg config file, if specified. 23 | pub config_file: Option, 24 | } 25 | 26 | impl Debug for WargRegistryConfig { 27 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 28 | f.debug_struct("WargRegistryConfig") 29 | .field("client_config", &self.client_config) 30 | .field("auth_token", &self.auth_token) 31 | .field("signing_key", &"[redacted]") 32 | .field("config_file", &self.config_file) 33 | .finish() 34 | } 35 | } 36 | 37 | impl TryFrom<&RegistryConfig> for WargRegistryConfig { 38 | type Error = Error; 39 | 40 | fn try_from(registry_config: &RegistryConfig) -> Result { 41 | let WargRegistryConfigToml { 42 | auth_token, 43 | signing_key, 44 | config_file, 45 | } = registry_config.backend_config("warg")?.unwrap_or_default(); 46 | let (client_config, config_file) = match config_file { 47 | Some(path) => ( 48 | warg_client::Config::from_file(&path).map_err(Error::RegistryError)?, 49 | Some(path), 50 | ), 51 | None => { 52 | // NOTE(thomastaylor312): We could try to be smarter here and see which file it 53 | // loaded, but there isn't a way to do that if it loaded from the current working 54 | // directory. 55 | ( 56 | warg_client::Config::from_default_file() 57 | .map_err(Error::RegistryError)? 58 | .unwrap_or_default(), 59 | None, 60 | ) 61 | } 62 | }; 63 | 64 | Ok(Self { 65 | client_config, 66 | auth_token, 67 | signing_key: signing_key 68 | .map(|k| PrivateKey::decode(k).map(Arc::new)) 69 | .transpose() 70 | .map_err(|e| { 71 | Error::InvalidConfig(anyhow::anyhow!("invalid signing key in config file: {e}")) 72 | })?, 73 | config_file, 74 | }) 75 | } 76 | } 77 | 78 | #[derive(Default, Deserialize, Serialize)] 79 | struct WargRegistryConfigToml { 80 | #[serde(skip_serializing_if = "Option::is_none")] 81 | config_file: Option, 82 | #[serde( 83 | skip_serializing_if = "Option::is_none", 84 | serialize_with = "serialize_secret" 85 | )] 86 | auth_token: Option, 87 | #[serde( 88 | skip_serializing_if = "Option::is_none", 89 | serialize_with = "serialize_secret" 90 | )] 91 | signing_key: Option, 92 | } 93 | 94 | impl From for WargRegistryConfigToml { 95 | fn from(value: WargRegistryConfig) -> Self { 96 | WargRegistryConfigToml { 97 | auth_token: value.auth_token, 98 | config_file: value.config_file, 99 | signing_key: value 100 | .signing_key 101 | .map(|k| SecretString::new(k.encode().to_string())), 102 | } 103 | } 104 | } 105 | 106 | fn serialize_secret( 107 | secret: &Option, 108 | serializer: S, 109 | ) -> Result { 110 | if let Some(secret) = secret { 111 | secret.expose_secret().serialize(serializer) 112 | } else { 113 | serializer.serialize_none() 114 | } 115 | } 116 | 117 | #[cfg(test)] 118 | mod tests { 119 | use wasm_pkg_common::config::RegistryMapping; 120 | 121 | use crate::warg::WargRegistryMetadata; 122 | 123 | use super::*; 124 | 125 | #[tokio::test] 126 | async fn test_warg_config_roundtrip() { 127 | let dir = tempfile::tempdir().expect("Unable to create tempdir"); 128 | let warg_config_path = dir.path().join("warg_config.json"); 129 | let (_, key) = warg_crypto::signing::generate_p256_pair(); 130 | let config = WargRegistryConfig { 131 | client_config: warg_client::Config { 132 | home_url: Some("https://example.com".to_owned()), 133 | ..Default::default() 134 | }, 135 | auth_token: Some("imsecret".to_owned().into()), 136 | signing_key: Some(Arc::new(key)), 137 | config_file: Some(warg_config_path.clone()), 138 | }; 139 | 140 | // Try loading it with the normal method to make sure it comes out right 141 | let mut conf = crate::Config::empty(); 142 | 143 | let registry: crate::Registry = "example.com:8080".parse().unwrap(); 144 | let reg_conf = conf.get_or_insert_registry_config_mut(®istry); 145 | reg_conf 146 | .set_backend_config("warg", &config) 147 | .expect("Unable to set config"); 148 | 149 | let reg_conf = conf.registry_config(®istry).unwrap(); 150 | 151 | // Write the warg config to disk 152 | tokio::fs::write( 153 | &warg_config_path, 154 | serde_json::to_vec(&config.client_config).unwrap(), 155 | ) 156 | .await 157 | .unwrap(); 158 | 159 | let roundtripped = WargRegistryConfig::try_from(reg_conf).expect("Unable to load config"); 160 | assert_eq!( 161 | roundtripped 162 | .client_config 163 | .home_url 164 | .expect("Should have a home url set"), 165 | config.client_config.home_url.unwrap(), 166 | "Home url should be set to the right value" 167 | ); 168 | assert_eq!( 169 | roundtripped 170 | .auth_token 171 | .expect("Should have an auth token set") 172 | .expose_secret(), 173 | config.auth_token.unwrap().expose_secret(), 174 | "Auth token should be set to the right value" 175 | ); 176 | assert_eq!( 177 | roundtripped 178 | .signing_key 179 | .expect("Should have a signing key set") 180 | .encode(), 181 | config.signing_key.unwrap().encode(), 182 | "Signing key should be set to the right value" 183 | ); 184 | } 185 | 186 | #[test] 187 | fn test_custom_namespace_config() { 188 | let toml_config = toml::toml! { 189 | [namespace_registries] 190 | test = { registry = "localhost:1234", metadata = { preferredProtocol = "warg", "warg" = { url = "http://localhost:1234" } } } 191 | }; 192 | 193 | let cfg = wasm_pkg_common::config::Config::from_toml(&toml_config.to_string()) 194 | .expect("Should be able to load config"); 195 | 196 | let ns_config = cfg 197 | .namespace_registry(&"test".parse().unwrap()) 198 | .expect("Should have a namespace config"); 199 | let custom = match ns_config { 200 | RegistryMapping::Custom(c) => c, 201 | _ => panic!("Should have a custom namespace config"), 202 | }; 203 | let map: WargRegistryMetadata = custom 204 | .metadata 205 | .protocol_config("warg") 206 | .expect("Should be able to deserialize config") 207 | .expect("protocol config should be present"); 208 | assert_eq!( 209 | map.url, 210 | Some("http://localhost:1234".into()), 211 | "URL should be set to the right value" 212 | ); 213 | } 214 | } 215 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/warg/loader.rs: -------------------------------------------------------------------------------- 1 | use anyhow::anyhow; 2 | use async_trait::async_trait; 3 | use futures_util::{StreamExt, TryStreamExt}; 4 | use wasm_pkg_common::{ 5 | package::{PackageRef, Version}, 6 | Error, 7 | }; 8 | 9 | use crate::{ 10 | loader::PackageLoader, 11 | release::{Release, VersionInfo}, 12 | ContentStream, 13 | }; 14 | 15 | use super::{package_ref_to_name, warg_registry_error, WargBackend}; 16 | 17 | #[async_trait] 18 | impl PackageLoader for WargBackend { 19 | async fn list_all_versions(&self, package: &PackageRef) -> Result, Error> { 20 | let info = self.fetch_package_info(package).await?; 21 | Ok(info 22 | .state 23 | .releases() 24 | .map(|r| VersionInfo { 25 | version: r.version.clone(), 26 | yanked: r.yanked(), 27 | }) 28 | .collect()) 29 | } 30 | 31 | async fn get_release(&self, package: &PackageRef, version: &Version) -> Result { 32 | let info = self.fetch_package_info(package).await?; 33 | let release = info 34 | .state 35 | .release(version) 36 | .ok_or_else(|| Error::VersionNotFound(version.clone()))?; 37 | let content_digest = release 38 | .content() 39 | .ok_or_else(|| Error::RegistryError(anyhow!("version {version} yanked")))? 40 | .to_string(); 41 | Ok(Release { 42 | version: version.clone(), 43 | content_digest: content_digest.parse()?, 44 | }) 45 | } 46 | 47 | async fn stream_content_unvalidated( 48 | &self, 49 | package: &PackageRef, 50 | release: &Release, 51 | ) -> Result { 52 | self.stream_content(package, release).await 53 | } 54 | 55 | async fn stream_content( 56 | &self, 57 | package: &PackageRef, 58 | release: &Release, 59 | ) -> Result { 60 | let package_name = package_ref_to_name(package)?; 61 | 62 | // warg client validates the digest matches the content 63 | let (_, stream) = self 64 | .client 65 | .download_exact_as_stream(&package_name, &release.version) 66 | .await 67 | .map_err(warg_registry_error)?; 68 | Ok(stream.map_err(Error::RegistryError).boxed()) 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/warg/mod.rs: -------------------------------------------------------------------------------- 1 | //! Warg package backend. 2 | 3 | use std::sync::Arc; 4 | 5 | use serde::Deserialize; 6 | use warg_client::{storage::PackageInfo, ClientError, FileSystemClient}; 7 | use warg_crypto::signing::PrivateKey; 8 | use warg_protocol::registry::PackageName; 9 | use wasm_pkg_common::{ 10 | config::RegistryConfig, metadata::RegistryMetadata, package::PackageRef, registry::Registry, 11 | Error, 12 | }; 13 | 14 | mod config; 15 | mod loader; 16 | mod publisher; 17 | 18 | /// Re-exported for convenience. 19 | pub use warg_client as client; 20 | 21 | pub use config::WargRegistryConfig; 22 | 23 | #[derive(Debug, Default, Deserialize)] 24 | #[serde(rename_all = "camelCase")] 25 | struct WargRegistryMetadata { 26 | url: Option, 27 | } 28 | 29 | pub(crate) struct WargBackend { 30 | client: FileSystemClient, 31 | signing_key: Option>, 32 | } 33 | 34 | impl WargBackend { 35 | pub async fn new( 36 | registry: &Registry, 37 | registry_config: &RegistryConfig, 38 | registry_meta: &RegistryMetadata, 39 | ) -> Result { 40 | let warg_meta = registry_meta 41 | .protocol_config::("warg")? 42 | .unwrap_or_default(); 43 | 44 | let WargRegistryConfig { 45 | client_config, 46 | auth_token, 47 | signing_key, 48 | .. 49 | } = registry_config.try_into()?; 50 | 51 | let url = warg_meta.url.unwrap_or_else(|| { 52 | // If we just pass registry plain, warg will assume it is https. This is a workaround to 53 | // assume that a local registry is http. 54 | if registry.host() == "localhost" || registry.host() == "127.0.0.1" { 55 | format!("http://{registry}") 56 | } else { 57 | format!("https://{registry}") 58 | } 59 | }); 60 | 61 | let client = 62 | FileSystemClient::new_with_config(Some(url.as_str()), &client_config, auth_token) 63 | .await 64 | .map_err(warg_registry_error)?; 65 | Ok(Self { 66 | client, 67 | signing_key, 68 | }) 69 | } 70 | 71 | pub(crate) async fn fetch_package_info( 72 | &self, 73 | package: &PackageRef, 74 | ) -> Result { 75 | let package_name = package_ref_to_name(package)?; 76 | // NOTE(thomastaylor312): We need to make sure we're up to date with all packages, but if we 77 | // bypass the cache every time, we'll have to fetch the whole package log every time rather 78 | // than loading from cache on disk. The remaining question here is the performance impact. 79 | // At scale, we don't know if this will result in a lot of HTTP requests even though the 80 | // packages were updated on a previous call. This should be good enough for now, but we 81 | // might need to revisit this later. 82 | self.client 83 | .update() 84 | .await 85 | .map_err(|e| Error::RegistryError(e.into()))?; 86 | self.client 87 | .package(&package_name) 88 | .await 89 | .map_err(warg_registry_error) 90 | } 91 | } 92 | 93 | pub(crate) fn package_ref_to_name(package_ref: &PackageRef) -> Result { 94 | PackageName::new(package_ref.to_string()) 95 | .map_err(|err| Error::InvalidPackageRef(err.to_string())) 96 | } 97 | 98 | pub(crate) fn warg_registry_error(err: ClientError) -> Error { 99 | match err { 100 | ClientError::PackageDoesNotExist { .. } 101 | | ClientError::PackageDoesNotExistWithHintHeader { .. } => Error::PackageNotFound, 102 | ClientError::PackageVersionDoesNotExist { version, .. } => Error::VersionNotFound(version), 103 | _ => Error::RegistryError(err.into()), 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/src/warg/publisher.rs: -------------------------------------------------------------------------------- 1 | use std::time::Duration; 2 | 3 | use futures_util::TryStreamExt; 4 | use tokio_util::codec::{BytesCodec, FramedRead}; 5 | use warg_client::storage::{ContentStorage, PublishEntry, PublishInfo}; 6 | 7 | use crate::publisher::PackagePublisher; 8 | use crate::{PackageRef, PublishingSource, Version}; 9 | 10 | use super::WargBackend; 11 | 12 | const DEFAULT_WAIT_INTERVAL: Duration = Duration::from_secs(1); 13 | 14 | #[async_trait::async_trait] 15 | impl PackagePublisher for WargBackend { 16 | async fn publish( 17 | &self, 18 | package: &PackageRef, 19 | version: &Version, 20 | data: PublishingSource, 21 | ) -> Result<(), crate::Error> { 22 | // store the Wasm in Warg cache, so that it is available to Warg client for uploading 23 | let content = self 24 | .client 25 | .content() 26 | .store_content( 27 | Box::pin( 28 | FramedRead::new(data, BytesCodec::new()) 29 | .map_ok(|b| b.freeze()) 30 | .map_err(anyhow::Error::from), 31 | ), 32 | None, 33 | ) 34 | .await 35 | .map_err(crate::Error::RegistryError)?; 36 | 37 | // convert package name to Warg package name 38 | let name = super::package_ref_to_name(package)?; 39 | 40 | // start Warg publish, using the keyring to sign 41 | let version = version.clone(); 42 | let info = PublishInfo { 43 | name: name.clone(), 44 | head: None, 45 | entries: vec![PublishEntry::Release { version, content }], 46 | }; 47 | let record_id = if let Some(key) = self.signing_key.as_ref() { 48 | self.client.publish_with_info(key, info).await 49 | } else { 50 | self.client.sign_with_keyring_and_publish(Some(info)).await 51 | } 52 | .map_err(super::warg_registry_error)?; 53 | 54 | // wait for the Warg publish to finish 55 | self.client 56 | .wait_for_publish(&name, &record_id, DEFAULT_WAIT_INTERVAL) 57 | .await 58 | .map_err(super::warg_registry_error)?; 59 | 60 | Ok(()) 61 | } 62 | } 63 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/tests/e2e.rs: -------------------------------------------------------------------------------- 1 | use futures_util::TryStreamExt; 2 | use testcontainers::{ 3 | core::{IntoContainerPort, WaitFor}, 4 | runners::AsyncRunner, 5 | GenericImage, ImageExt, 6 | }; 7 | use wasm_pkg_client::{Client, Config}; 8 | 9 | const FIXTURE_WASM: &str = "./tests/testdata/binary_wit.wasm"; 10 | 11 | #[cfg(any(target_os = "linux", feature = "_local"))] 12 | // NOTE: These are only run on linux for CI purposes, because they rely on the docker client being 13 | // available, and for various reasons this has proven to be problematic on both the Windows and 14 | // MacOS runners due to it not being installed (yay licensing). 15 | #[tokio::test] 16 | async fn publish_and_fetch_smoke_test() { 17 | let _container = GenericImage::new("registry", "2") 18 | .with_wait_for(WaitFor::message_on_stderr("listening on [::]:5000")) 19 | .with_mapped_port(5001, 5000.tcp()) 20 | .start() 21 | .await 22 | .expect("Failed to start test container"); 23 | // Fetch package 24 | let config = Config::from_toml( 25 | r#" 26 | default_registry = "localhost:5001" 27 | 28 | [registry."localhost:5001"] 29 | type = "oci" 30 | [registry."localhost:5001".oci] 31 | protocol = "http" 32 | "#, 33 | ) 34 | .unwrap(); 35 | let client = Client::new(config); 36 | 37 | let (package, _version) = client 38 | .publish_release_file(FIXTURE_WASM, Default::default()) 39 | .await 40 | .expect("Failed to publish file"); 41 | 42 | let versions = client.list_all_versions(&package).await.unwrap(); 43 | let version = versions.into_iter().next().unwrap(); 44 | assert_eq!(version.to_string(), "0.2.0"); 45 | 46 | let release = client 47 | .get_release(&package, &version.version) 48 | .await 49 | .unwrap(); 50 | let content = client 51 | .stream_content(&package, &release) 52 | .await 53 | .unwrap() 54 | .try_collect::() 55 | .await 56 | .unwrap(); 57 | let expected_content = tokio::fs::read(FIXTURE_WASM) 58 | .await 59 | .expect("Failed to read fixture"); 60 | assert_eq!(content, expected_content); 61 | } 62 | 63 | // Simple smoke test to make sure the custom metadata section is parsed and used correctly. Down the 64 | // line we might want to just push a thing to a local registry and then fetch it, but for now we'll 65 | // just use the bytecodealliance registry. 66 | #[tokio::test] 67 | async fn fetch_with_custom_config() { 68 | let toml_config = toml::toml! { 69 | [namespace_registries] 70 | wasi = { registry = "fake.com:1234", metadata = { preferredProtocol = "oci", "oci" = {registry = "ghcr.io", namespacePrefix = "bytecodealliance/wasm-pkg/" } } } 71 | }; 72 | 73 | let conf = Config::from_toml(&toml_config.to_string()).expect("Failed to parse config"); 74 | let client = Client::new(conf); 75 | 76 | // Try listing all versions of the wasi package and make sure it doesn't fail 77 | let package = "wasi:http".parse().unwrap(); 78 | let versions = client 79 | .list_all_versions(&package) 80 | .await 81 | .expect("Should be able to list versions with custom config"); 82 | assert!( 83 | !versions.is_empty(), 84 | "Should be able to list versions with custom config" 85 | ); 86 | } 87 | -------------------------------------------------------------------------------- /crates/wasm-pkg-client/tests/testdata/binary_wit.wasm: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bytecodealliance/wasm-pkg-tools/5221bdca410563e2087d8adc36425361859e7263/crates/wasm-pkg-client/tests/testdata/binary_wit.wasm -------------------------------------------------------------------------------- /crates/wasm-pkg-common/Cargo.toml: -------------------------------------------------------------------------------- 1 | [package] 2 | name = "wasm-pkg-common" 3 | description = "Wasm Package common types and configuration" 4 | repository = "https://github.com/bytecodealliance/wasm-pkg-tools/tree/main/crates/wasm-pkg-common" 5 | edition.workspace = true 6 | version.workspace = true 7 | authors.workspace = true 8 | license.workspace = true 9 | readme = "../../README.md" 10 | 11 | [features] 12 | metadata-client = ["dep:reqwest"] 13 | tokio = ["tokio/io-util"] 14 | # Extra features to facilitate making working with OCI images easier 15 | oci_extras = [] 16 | 17 | [dependencies] 18 | anyhow = { workspace = true } 19 | bytes = { workspace = true } 20 | etcetera = { workspace = true } 21 | futures-util = { workspace = true } 22 | http = "1.1.0" 23 | reqwest = { version = "0.12.0", default-features = false, features = [ 24 | "rustls-tls", 25 | "charset", 26 | "http2", 27 | "macos-system-configuration", 28 | "json", 29 | ], optional = true } 30 | semver = { workspace = true } 31 | serde = { workspace = true } 32 | serde_json = { workspace = true } 33 | sha2 = { workspace = true } 34 | tokio = { workspace = true, features = ["fs"] } 35 | toml = { workspace = true } 36 | thiserror = { workspace = true } 37 | tracing = { workspace = true } 38 | 39 | [dev-dependencies] 40 | tokio = { workspace = true, features = ["macros", "rt"] } 41 | -------------------------------------------------------------------------------- /crates/wasm-pkg-common/src/config/toml.rs: -------------------------------------------------------------------------------- 1 | // TODO: caused by inner bytes::Bytes; probably fixed in Rust 1.79 2 | #![allow(clippy::mutable_key_type)] 3 | 4 | use std::collections::HashMap; 5 | 6 | use serde::{Deserialize, Serialize}; 7 | 8 | use crate::{label::Label, package::PackageRef, registry::Registry}; 9 | 10 | use super::RegistryMapping; 11 | 12 | #[derive(Deserialize, Serialize)] 13 | #[serde(deny_unknown_fields)] 14 | pub struct TomlConfig { 15 | default_registry: Option, 16 | #[serde(default)] 17 | namespace_registries: HashMap, 18 | #[serde(default)] 19 | package_registry_overrides: HashMap, 20 | #[serde(default)] 21 | registry: HashMap, 22 | } 23 | 24 | impl From for super::Config { 25 | fn from(value: TomlConfig) -> Self { 26 | let TomlConfig { 27 | default_registry, 28 | namespace_registries, 29 | package_registry_overrides, 30 | registry, 31 | } = value; 32 | 33 | let registry_configs = registry 34 | .into_iter() 35 | .map(|(reg, config)| (reg, config.into())) 36 | .collect(); 37 | 38 | Self { 39 | default_registry, 40 | namespace_registries, 41 | package_registry_overrides, 42 | fallback_namespace_registries: Default::default(), 43 | registry_configs, 44 | } 45 | } 46 | } 47 | 48 | impl From for TomlConfig { 49 | fn from(value: super::Config) -> Self { 50 | let registry = value 51 | .registry_configs 52 | .into_iter() 53 | .map(|(reg, config)| (reg, config.into())) 54 | .collect(); 55 | Self { 56 | default_registry: value.default_registry, 57 | namespace_registries: value.namespace_registries, 58 | package_registry_overrides: value.package_registry_overrides, 59 | registry, 60 | } 61 | } 62 | } 63 | 64 | #[derive(Deserialize, Serialize)] 65 | struct TomlRegistryConfig { 66 | #[serde(alias = "type")] 67 | default: Option, 68 | #[serde(flatten)] 69 | backend_configs: HashMap, 70 | } 71 | 72 | impl From for super::RegistryConfig { 73 | fn from(value: TomlRegistryConfig) -> Self { 74 | let TomlRegistryConfig { 75 | default, 76 | backend_configs, 77 | } = value; 78 | Self { 79 | default_backend: default, 80 | backend_configs, 81 | } 82 | } 83 | } 84 | 85 | impl From for TomlRegistryConfig { 86 | fn from(value: super::RegistryConfig) -> Self { 87 | let super::RegistryConfig { 88 | default_backend: backend_default, 89 | backend_configs, 90 | } = value; 91 | Self { 92 | default: backend_default, 93 | backend_configs, 94 | } 95 | } 96 | } 97 | 98 | #[cfg(test)] 99 | mod tests { 100 | use super::*; 101 | 102 | #[test] 103 | fn smoke_test() { 104 | let toml_config = toml::toml! { 105 | default_registry = "example.com" 106 | 107 | [namespace_registries] 108 | wasi = "wasi.dev" 109 | 110 | [package_registry_overrides] 111 | "example:foo" = "example.com" 112 | 113 | [registry."wasi.dev".oci] 114 | auth = { username = "open", password = "sesame" } 115 | 116 | [registry."example.com"] 117 | type = "test" 118 | test = { token = "top_secret" } 119 | }; 120 | let wasi_dev: Registry = "wasi.dev".parse().unwrap(); 121 | let example_com: Registry = "example.com".parse().unwrap(); 122 | 123 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 124 | let cfg = crate::config::Config::from(toml_cfg); 125 | 126 | assert_eq!(cfg.default_registry(), Some(&example_com)); 127 | assert_eq!( 128 | cfg.resolve_registry(&"wasi:http".parse().unwrap()), 129 | Some(&wasi_dev) 130 | ); 131 | assert_eq!( 132 | cfg.resolve_registry(&"example:foo".parse().unwrap()), 133 | Some(&example_com) 134 | ); 135 | 136 | #[derive(Deserialize)] 137 | struct TestConfig { 138 | token: String, 139 | } 140 | let test_cfg: TestConfig = cfg 141 | .registry_config(&example_com) 142 | .unwrap() 143 | .backend_config("test") 144 | .unwrap() 145 | .unwrap(); 146 | assert_eq!(test_cfg.token, "top_secret"); 147 | } 148 | 149 | #[test] 150 | fn type_parses_correctly() { 151 | let toml_config = toml::toml! { 152 | [namespace_registries] 153 | test = "localhost:1234" 154 | 155 | [package_registry_overrides] 156 | 157 | [registry."localhost:1234".warg] 158 | config_file = "/a/path" 159 | }; 160 | 161 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 162 | let cfg = crate::config::Config::from(toml_cfg); 163 | let reg_conf = cfg 164 | .registry_config(&"localhost:1234".parse().unwrap()) 165 | .expect("Should have config for registry"); 166 | assert_eq!( 167 | reg_conf 168 | .default_backend() 169 | .expect("Should have a default set"), 170 | "warg" 171 | ); 172 | 173 | let toml_config = toml::toml! { 174 | [namespace_registries] 175 | test = "localhost:1234" 176 | 177 | [package_registry_overrides] 178 | 179 | [registry."localhost:1234".warg] 180 | config_file = "/a/path" 181 | [registry."localhost:1234".oci] 182 | auth = { username = "open", password = "sesame" } 183 | }; 184 | 185 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 186 | let cfg = crate::config::Config::from(toml_cfg); 187 | let reg_conf = cfg 188 | .registry_config(&"localhost:1234".parse().unwrap()) 189 | .expect("Should have config for registry"); 190 | assert!( 191 | reg_conf.default_backend().is_none(), 192 | "Should not have a type set when two configs exist" 193 | ); 194 | 195 | let toml_config = toml::toml! { 196 | [namespace_registries] 197 | test = "localhost:1234" 198 | 199 | [package_registry_overrides] 200 | 201 | [registry."localhost:1234"] 202 | type = "foobar" 203 | [registry."localhost:1234".warg] 204 | config_file = "/a/path" 205 | [registry."localhost:1234".oci] 206 | auth = { username = "open", password = "sesame" } 207 | }; 208 | 209 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 210 | let cfg = crate::config::Config::from(toml_cfg); 211 | let reg_conf = cfg 212 | .registry_config(&"localhost:1234".parse().unwrap()) 213 | .expect("Should have config for registry"); 214 | assert_eq!( 215 | reg_conf 216 | .default_backend() 217 | .expect("Should have a default set using the type alias"), 218 | "foobar" 219 | ); 220 | 221 | let toml_config = toml::toml! { 222 | [namespace_registries] 223 | test = "localhost:1234" 224 | 225 | [registry."localhost:1234"] 226 | default = "foobar" 227 | [registry."localhost:1234".warg] 228 | config_file = "/a/path" 229 | [registry."localhost:1234".oci] 230 | auth = { username = "open", password = "sesame" } 231 | }; 232 | 233 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 234 | let cfg = crate::config::Config::from(toml_cfg); 235 | let reg_conf = cfg 236 | .registry_config(&"localhost:1234".parse().unwrap()) 237 | .expect("Should have config for registry"); 238 | assert_eq!( 239 | reg_conf 240 | .default_backend() 241 | .expect("Should have a default set"), 242 | "foobar" 243 | ); 244 | } 245 | 246 | #[test] 247 | fn test_custom_namespace_config() { 248 | let toml_config = toml::toml! { 249 | [namespace_registries] 250 | test = { registry = "localhost", metadata = { preferredProtocol = "oci", "oci" = {registry = "ghcr.io", namespacePrefix = "webassembly/" } } } 251 | foo = "foo:1234" 252 | 253 | [package_registry_overrides] 254 | "foo:bar" = { registry = "localhost", metadata = { preferredProtocol = "oci", "oci" = {registry = "ghcr.io", namespacePrefix = "webassembly/" } } } 255 | 256 | [registry."localhost".oci] 257 | auth = { username = "open", password = "sesame" } 258 | }; 259 | 260 | let toml_cfg: TomlConfig = toml_config.try_into().unwrap(); 261 | let cfg = crate::config::Config::from(toml_cfg); 262 | 263 | // First check the the normal string case works 264 | let ns_config = cfg 265 | .namespace_registry(&"foo".parse().unwrap()) 266 | .expect("Should have a namespace config"); 267 | let reg = match ns_config { 268 | RegistryMapping::Registry(r) => r, 269 | _ => panic!("Should have a registry namespace config"), 270 | }; 271 | assert_eq!( 272 | reg, 273 | &"foo:1234".parse::().unwrap(), 274 | "Should have a registry" 275 | ); 276 | 277 | let ns_config = cfg 278 | .namespace_registry(&"test".parse().unwrap()) 279 | .expect("Should have a namespace config"); 280 | let custom = match ns_config { 281 | RegistryMapping::Custom(c) => c, 282 | _ => panic!("Should have a custom namespace config"), 283 | }; 284 | assert_eq!( 285 | custom.registry, 286 | "localhost".parse().unwrap(), 287 | "Should have a registry" 288 | ); 289 | assert_eq!( 290 | custom.metadata.preferred_protocol(), 291 | Some("oci"), 292 | "Should have a preferred protocol" 293 | ); 294 | // Specific deserializations are tested in the client model 295 | let map = custom 296 | .metadata 297 | .protocol_configs 298 | .get("oci") 299 | .expect("Should have a protocol config"); 300 | assert_eq!( 301 | map.get("registry").expect("registry should exist"), 302 | "ghcr.io", 303 | "Should have a registry" 304 | ); 305 | assert_eq!( 306 | map.get("namespacePrefix") 307 | .expect("namespacePrefix should exist"), 308 | "webassembly/", 309 | "Should have a namespace prefix" 310 | ); 311 | 312 | // Now test the same thing for a package override 313 | let ns_config = cfg 314 | .package_registry_override(&"foo:bar".parse().unwrap()) 315 | .expect("Should have a package override config"); 316 | let custom = match ns_config { 317 | RegistryMapping::Custom(c) => c, 318 | _ => panic!("Should have a custom namespace config"), 319 | }; 320 | assert_eq!( 321 | custom.registry, 322 | "localhost".parse().unwrap(), 323 | "Should have a registry" 324 | ); 325 | assert_eq!( 326 | custom.metadata.preferred_protocol(), 327 | Some("oci"), 328 | "Should have a preferred protocol" 329 | ); 330 | // Specific deserializations are tested in the client model 331 | let map = custom 332 | .metadata 333 | .protocol_configs 334 | .get("oci") 335 | .expect("Should have a protocol config"); 336 | assert_eq!( 337 | map.get("registry").expect("registry should exist"), 338 | "ghcr.io", 339 | "Should have a registry" 340 | ); 341 | assert_eq!( 342 | map.get("namespacePrefix") 343 | .expect("namespacePrefix should exist"), 344 | "webassembly/", 345 | "Should have a namespace prefix" 346 | ); 347 | } 348 | } 349 | -------------------------------------------------------------------------------- /crates/wasm-pkg-common/src/digest.rs: -------------------------------------------------------------------------------- 1 | #[cfg(feature = "tokio")] 2 | use std::path::Path; 3 | use std::str::FromStr; 4 | 5 | use bytes::Bytes; 6 | use futures_util::{future::ready, stream::once, Stream, StreamExt, TryStream, TryStreamExt}; 7 | use serde::{Deserialize, Serialize}; 8 | use sha2::{Digest, Sha256}; 9 | 10 | use crate::Error; 11 | 12 | /// A cryptographic digest (hash) of some content. 13 | #[derive(Clone, Debug, PartialEq, Eq)] 14 | pub enum ContentDigest { 15 | Sha256 { hex: String }, 16 | } 17 | 18 | impl ContentDigest { 19 | #[cfg(feature = "tokio")] 20 | pub async fn sha256_from_file(path: impl AsRef) -> Result { 21 | use tokio::io::AsyncReadExt; 22 | let mut file = tokio::fs::File::open(path).await?; 23 | let mut hasher = Sha256::new(); 24 | let mut buf = [0; 4096]; 25 | loop { 26 | let n = file.read(&mut buf).await?; 27 | if n == 0 { 28 | break; 29 | } 30 | hasher.update(&buf[..n]); 31 | } 32 | Ok(hasher.into()) 33 | } 34 | 35 | pub fn validating_stream( 36 | &self, 37 | stream: impl TryStream, 38 | ) -> impl Stream> { 39 | let want = self.clone(); 40 | stream.map_ok(Some).chain(once(async { Ok(None) })).scan( 41 | Sha256::new(), 42 | move |hasher, res| { 43 | ready(match res { 44 | Ok(Some(bytes)) => { 45 | hasher.update(&bytes); 46 | Some(Ok(bytes)) 47 | } 48 | Ok(None) => { 49 | let got: Self = std::mem::take(hasher).into(); 50 | if got == want { 51 | None 52 | } else { 53 | Some(Err(Error::InvalidContent(format!( 54 | "expected digest {want}, got {got}" 55 | )))) 56 | } 57 | } 58 | Err(err) => Some(Err(err)), 59 | }) 60 | }, 61 | ) 62 | } 63 | } 64 | 65 | impl std::fmt::Display for ContentDigest { 66 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 67 | match self { 68 | ContentDigest::Sha256 { hex } => write!(f, "sha256:{hex}"), 69 | } 70 | } 71 | } 72 | 73 | impl From for ContentDigest { 74 | fn from(hasher: Sha256) -> Self { 75 | Self::Sha256 { 76 | hex: format!("{:x}", hasher.finalize()), 77 | } 78 | } 79 | } 80 | 81 | impl<'a> TryFrom<&'a str> for ContentDigest { 82 | type Error = Error; 83 | 84 | fn try_from(value: &'a str) -> Result { 85 | let Some(hex) = value.strip_prefix("sha256:") else { 86 | return Err(Error::InvalidContentDigest( 87 | "must start with 'sha256:'".into(), 88 | )); 89 | }; 90 | let hex = hex.to_lowercase(); 91 | if hex.len() != 64 { 92 | return Err(Error::InvalidContentDigest(format!( 93 | "must be 64 hex digits; got {} chars", 94 | hex.len() 95 | ))); 96 | } 97 | if let Some(invalid) = hex.chars().find(|c| !c.is_ascii_hexdigit()) { 98 | return Err(Error::InvalidContentDigest(format!( 99 | "must be hex; got {invalid:?}" 100 | ))); 101 | } 102 | Ok(Self::Sha256 { hex }) 103 | } 104 | } 105 | 106 | impl FromStr for ContentDigest { 107 | type Err = Error; 108 | 109 | fn from_str(s: &str) -> Result { 110 | s.try_into() 111 | } 112 | } 113 | 114 | impl Serialize for ContentDigest { 115 | fn serialize(&self, serializer: S) -> Result { 116 | serializer.serialize_str(&self.to_string()) 117 | } 118 | } 119 | 120 | impl<'de> Deserialize<'de> for ContentDigest { 121 | fn deserialize(deserializer: D) -> Result 122 | where 123 | D: serde::Deserializer<'de>, 124 | { 125 | Self::from_str(&String::deserialize(deserializer)?).map_err(serde::de::Error::custom) 126 | } 127 | } 128 | 129 | #[cfg(test)] 130 | mod tests { 131 | use bytes::BytesMut; 132 | use futures_util::stream; 133 | 134 | use super::*; 135 | 136 | #[tokio::test] 137 | async fn test_validating_stream() { 138 | let input = b"input"; 139 | let digest = ContentDigest::from(Sha256::new_with_prefix(input)); 140 | let stream = stream::iter(input.chunks(2)); 141 | let validating = digest.validating_stream(stream.map(|bytes| Ok(bytes.into()))); 142 | assert_eq!( 143 | validating.try_collect::().await.unwrap(), 144 | &input[..] 145 | ); 146 | } 147 | 148 | #[tokio::test] 149 | async fn test_invalidating_stream() { 150 | let input = b"input"; 151 | let digest = ContentDigest::Sha256 { 152 | hex: "doesn't match anything!".to_string(), 153 | }; 154 | let stream = stream::iter(input.chunks(2)); 155 | let validating = digest.validating_stream(stream.map(|bytes| Ok(bytes.into()))); 156 | assert!(matches!( 157 | validating.try_collect::().await, 158 | Err(Error::InvalidContent(_)), 159 | )); 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /crates/wasm-pkg-common/src/label.rs: -------------------------------------------------------------------------------- 1 | use serde::{Deserialize, Serialize}; 2 | 3 | /// A Component Model kebab-case label. 4 | #[derive(Clone, PartialEq, Eq, Hash, Ord, PartialOrd, Serialize, Deserialize)] 5 | #[serde(into = "String", try_from = "String")] 6 | pub struct Label(String); 7 | 8 | impl AsRef for Label { 9 | fn as_ref(&self) -> &str { 10 | &self.0 11 | } 12 | } 13 | 14 | impl std::fmt::Display for Label { 15 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 16 | write!(f, "{}", self.0) 17 | } 18 | } 19 | 20 | impl std::fmt::Debug for Label { 21 | fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { 22 | write!(f, "{:?}", self.0) 23 | } 24 | } 25 | 26 | impl From