├── .github ├── ISSUE_TEMPLATE │ ├── bug-report.yaml │ └── feature-request.yaml └── workflows │ ├── dry-build.yml │ ├── integration-tests.yml │ ├── lint.yml │ ├── release.yml │ └── unit-tests.yml ├── .gitignore ├── .golangci.yml ├── LICENSE ├── README.md ├── cmd ├── auth.go ├── common.go ├── constants.go ├── download.go ├── output_format.go ├── purchase.go ├── root.go └── search.go ├── demo.gif ├── go.mod ├── go.sum ├── main.go ├── pkg ├── appstore │ ├── account.go │ ├── app.go │ ├── app_test.go │ ├── appstore.go │ ├── appstore_account_info.go │ ├── appstore_account_info_test.go │ ├── appstore_download.go │ ├── appstore_download_test.go │ ├── appstore_login.go │ ├── appstore_login_test.go │ ├── appstore_lookup.go │ ├── appstore_lookup_test.go │ ├── appstore_purchase.go │ ├── appstore_purchase_test.go │ ├── appstore_replicate_sinf.go │ ├── appstore_replicate_sinf_test.go │ ├── appstore_revoke.go │ ├── appstore_revoke_test.go │ ├── appstore_search.go │ ├── appstore_search_test.go │ ├── appstore_test.go │ ├── constants.go │ ├── error.go │ └── storefront.go ├── http │ ├── client.go │ ├── client_test.go │ ├── constants.go │ ├── cookiejar.go │ ├── http_test.go │ ├── method.go │ ├── payload.go │ ├── payload_test.go │ ├── request.go │ └── result.go ├── keychain │ ├── keychain.go │ ├── keychain_get.go │ ├── keychain_get_test.go │ ├── keychain_remove.go │ ├── keychain_remove_test.go │ ├── keychain_set.go │ ├── keychain_set_test.go │ ├── keychain_test.go │ └── keyring.go ├── log │ ├── log_test.go │ ├── logger.go │ ├── logger_test.go │ ├── writer.go │ └── writer_test.go └── util │ ├── machine │ ├── machine.go │ └── machine_test.go │ ├── must.go │ ├── must_test.go │ ├── operatingsystem │ ├── operatingsystem.go │ └── operatingsystem_test.go │ ├── string.go │ ├── string_test.go │ ├── util_test.go │ ├── zip.go │ └── zip_test.go ├── tools.go └── tools └── sha256sum.sh /.github/ISSUE_TEMPLATE/bug-report.yaml: -------------------------------------------------------------------------------- 1 | name: Bug Report 2 | description: File a bug report 3 | labels: 4 | - bug 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: What happened? 10 | description: Also share, what did you expect to happen? 11 | validations: 12 | required: true 13 | - type: input 14 | id: version 15 | attributes: 16 | label: Version 17 | description: What version of ipatool are you running? 18 | validations: 19 | required: true 20 | - type: textarea 21 | id: logs 22 | attributes: 23 | label: Relevant log output 24 | description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. 25 | render: shell 26 | -------------------------------------------------------------------------------- /.github/ISSUE_TEMPLATE/feature-request.yaml: -------------------------------------------------------------------------------- 1 | name: Feature Request 2 | description: Submit a feature request 3 | labels: 4 | - feature request 5 | body: 6 | - type: textarea 7 | id: description 8 | attributes: 9 | label: Description 10 | description: Please provide details about the desired feature. 11 | validations: 12 | required: true 13 | -------------------------------------------------------------------------------- /.github/workflows/dry-build.yml: -------------------------------------------------------------------------------- 1 | name: Dry Build 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build_windows: 10 | name: Build for Windows 11 | runs-on: macos-latest 12 | strategy: 13 | fail-fast: false 14 | matrix: 15 | arch: [ arm64, amd64 ] 16 | steps: 17 | - uses: actions/checkout@v2 18 | - uses: actions/setup-go@v3 19 | with: 20 | go-version: '1.19.3' 21 | cache: true 22 | - run: go build -o ipatool-$GOOS-$GOARCH.exe 23 | env: 24 | GOOS: windows 25 | GOARCH: ${{ matrix.arch }} 26 | build_linux: 27 | name: Build for Linux 28 | runs-on: macos-latest 29 | strategy: 30 | fail-fast: false 31 | matrix: 32 | arch: [ arm64, amd64 ] 33 | steps: 34 | - uses: actions/checkout@v2 35 | - uses: actions/setup-go@v3 36 | with: 37 | go-version: '1.19.3' 38 | cache: true 39 | - run: go build -o ipatool-$GOOS-$GOARCH 40 | env: 41 | GOOS: linux 42 | GOARCH: ${{ matrix.arch }} 43 | build_macos: 44 | name: Build for macOS 45 | runs-on: macos-latest 46 | strategy: 47 | fail-fast: false 48 | matrix: 49 | arch: [ arm64, amd64 ] 50 | steps: 51 | - uses: actions/checkout@v2 52 | - uses: actions/setup-go@v3 53 | with: 54 | go-version: '1.19.3' 55 | cache: true 56 | - run: go build -o ipatool-$GOOS-$GOARCH 57 | env: 58 | GOOS: darwin 59 | GOARCH: ${{ matrix.arch }} 60 | CGO_CFLAGS: -mmacosx-version-min=10.15 61 | CGO_LDFLAGS: -mmacosx-version-min=10.15 -------------------------------------------------------------------------------- /.github/workflows/integration-tests.yml: -------------------------------------------------------------------------------- 1 | name: Integration Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | build: 10 | name: Build 11 | runs-on: macos-latest 12 | strategy: 13 | fail-fast: false 14 | steps: 15 | - uses: actions/checkout@v2 16 | - uses: actions/setup-go@v3 17 | with: 18 | go-version: '1.19.3' 19 | cache: true 20 | - run: go build -o ipatool 21 | env: 22 | CGO_CFLAGS: -mmacosx-version-min=10.15 23 | CGO_LDFLAGS: -mmacosx-version-min=10.15 24 | - uses: actions/upload-artifact@v4 25 | with: 26 | name: ipatool 27 | path: ipatool 28 | if-no-files-found: error 29 | test: 30 | name: Test 31 | runs-on: macos-latest 32 | needs: [build] 33 | strategy: 34 | fail-fast: false 35 | matrix: 36 | command: [auth, download, purchase, search] 37 | steps: 38 | - uses: actions/download-artifact@v4 39 | with: 40 | name: ipatool 41 | path: build 42 | - run: chmod +x ./build/ipatool 43 | - run: ./build/ipatool ${{ matrix.command }} --help -------------------------------------------------------------------------------- /.github/workflows/lint.yml: -------------------------------------------------------------------------------- 1 | name: Lint 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | lint: 10 | name: Lint 11 | runs-on: macos-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: '1.19.3' 17 | cache: true 18 | - run: go generate github.com/majd/ipatool/... 19 | - uses: golangci/golangci-lint-action@v3 20 | with: 21 | version: v1.52.2 22 | -------------------------------------------------------------------------------- /.github/workflows/release.yml: -------------------------------------------------------------------------------- 1 | name: Release 2 | 3 | on: 4 | push: 5 | tags: 6 | - "v*" 7 | 8 | jobs: 9 | get_version: 10 | name: Get version 11 | runs-on: ubuntu-latest 12 | steps: 13 | - id: set_output 14 | run: echo ::set-output name=version::${GITHUB_REF#refs/tags/v} 15 | outputs: 16 | version: ${{ steps.set_output.outputs.version }} 17 | test: 18 | name: Run tests 19 | runs-on: macos-latest 20 | steps: 21 | - uses: actions/checkout@v2 22 | - uses: actions/setup-go@v3 23 | with: 24 | go-version: '1.19.3' 25 | cache: true 26 | - run: go generate github.com/majd/ipatool/... 27 | - run: go test -v github.com/majd/ipatool/... 28 | build: 29 | name: Build 30 | runs-on: macos-latest 31 | needs: [get_version, test] 32 | steps: 33 | - uses: actions/checkout@v2 34 | - uses: actions/setup-go@v3 35 | with: 36 | go-version: '1.19.3' 37 | cache: true 38 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-arm64.exe 39 | env: 40 | GOOS: windows 41 | GOARCH: arm64 42 | VERSION: ${{ needs.get_version.outputs.version }} 43 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-amd64.exe 44 | env: 45 | GOOS: windows 46 | GOARCH: amd64 47 | VERSION: ${{ needs.get_version.outputs.version }} 48 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-arm64 49 | env: 50 | GOOS: linux 51 | GOARCH: arm64 52 | VERSION: ${{ needs.get_version.outputs.version }} 53 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-amd64 54 | env: 55 | GOOS: linux 56 | GOARCH: amd64 57 | VERSION: ${{ needs.get_version.outputs.version }} 58 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-arm64 59 | env: 60 | GOOS: darwin 61 | GOARCH: arm64 62 | VERSION: ${{ needs.get_version.outputs.version }} 63 | CGO_CFLAGS: -mmacosx-version-min=10.15 64 | CGO_LDFLAGS: -mmacosx-version-min=10.15 65 | CGO_ENABLED: 1 66 | - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-amd64 67 | env: 68 | GOOS: darwin 69 | GOARCH: amd64 70 | VERSION: ${{ needs.get_version.outputs.version }} 71 | CGO_CFLAGS: -mmacosx-version-min=10.15 72 | CGO_LDFLAGS: -mmacosx-version-min=10.15 73 | CGO_ENABLED: 1 74 | - uses: actions/upload-artifact@v4 75 | with: 76 | name: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe 77 | path: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe 78 | if-no-files-found: error 79 | - uses: actions/upload-artifact@v4 80 | with: 81 | name: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe 82 | path: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe 83 | if-no-files-found: error 84 | - uses: actions/upload-artifact@v4 85 | with: 86 | name: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64 87 | path: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64 88 | if-no-files-found: error 89 | - uses: actions/upload-artifact@v4 90 | with: 91 | name: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64 92 | path: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64 93 | if-no-files-found: error 94 | - uses: actions/upload-artifact@v4 95 | with: 96 | name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 97 | path: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 98 | if-no-files-found: error 99 | - uses: actions/upload-artifact@v4 100 | with: 101 | name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 102 | path: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 103 | if-no-files-found: error 104 | release_windows: 105 | name: Release for Windows 106 | runs-on: ubuntu-latest 107 | needs: [get_version, build] 108 | strategy: 109 | fail-fast: false 110 | matrix: 111 | arch: [ arm64, amd64 ] 112 | steps: 113 | - uses: actions/checkout@v2 114 | - uses: actions/download-artifact@v4 115 | with: 116 | name: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.exe 117 | path: bin 118 | - run: tar -czvf $FILE.tar.gz bin/$FILE.exe 119 | env: 120 | FILE: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }} 121 | - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum 122 | env: 123 | TARBALL: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.tar.gz 124 | - uses: svenstaro/upload-release-action@v2 125 | with: 126 | repo_token: ${{ secrets.GITHUB_TOKEN }} 127 | file: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.* 128 | tag: ${{ github.ref }} 129 | overwrite: false 130 | file_glob: true 131 | release_linux: 132 | name: Release for Linux 133 | runs-on: ubuntu-latest 134 | needs: [get_version, build, release_windows] 135 | strategy: 136 | fail-fast: false 137 | matrix: 138 | arch: [ arm64, amd64 ] 139 | steps: 140 | - uses: actions/checkout@v2 141 | - uses: actions/download-artifact@v4 142 | with: 143 | name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} 144 | path: bin 145 | - run: tar -czvf $FILE.tar.gz bin/$FILE 146 | env: 147 | FILE: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} 148 | - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum 149 | env: 150 | TARBALL: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz 151 | - uses: svenstaro/upload-release-action@v2 152 | with: 153 | repo_token: ${{ secrets.GITHUB_TOKEN }} 154 | file: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.* 155 | tag: ${{ github.ref }} 156 | overwrite: false 157 | file_glob: true 158 | release_macos: 159 | name: Release for macOS 160 | runs-on: ubuntu-latest 161 | needs: [get_version, build, release_windows, release_linux] 162 | steps: 163 | - uses: actions/checkout@v2 164 | with: 165 | path: ./ipatool 166 | - uses: actions/download-artifact@v4 167 | with: 168 | name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 169 | path: bin 170 | - run: tar -czvf $BIN.tar.gz bin/$BIN && rm -rf bin/ 171 | env: 172 | BIN: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 173 | - uses: actions/download-artifact@v4 174 | with: 175 | name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 176 | path: bin 177 | - run: tar -czvf $FILE.tar.gz bin/$FILE && rm -rf bin/ 178 | env: 179 | FILE: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 180 | - id: sha256 181 | run: | 182 | SHA256_ARM64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz) 183 | SHA256_AMD64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz) 184 | echo $SHA256_ARM64 > ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz.sha256sum 185 | echo $SHA256_AMD64 > ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz.sha256sum 186 | echo ::set-output name=sha256_arm64::$SHA256_ARM64 187 | echo ::set-output name=sha256_amd64::$SHA256_AMD64 188 | - uses: svenstaro/upload-release-action@v2 189 | with: 190 | repo_token: ${{ secrets.GITHUB_TOKEN }} 191 | file: ipatool-${{ needs.get_version.outputs.version }}-macos-* 192 | tag: ${{ github.ref }} 193 | overwrite: false 194 | file_glob: true 195 | - uses: actions/checkout@v2 196 | with: 197 | repository: ${{ secrets.HOMEBREW_REPO }} 198 | ref: main 199 | token: ${{ secrets.GH_TOKEN }} 200 | path: homebrew-repo 201 | - run: | 202 | cd homebrew-repo 203 | sed -i "3s/.*/ sha256 \"$SHA256_ARM64\"/" Casks/ipatool.rb 204 | sed -i "4s/.*/ url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz\"/" Casks/ipatool.rb 205 | sed -i "5s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64\", target: \"ipatool\"/" Casks/ipatool.rb 206 | sed -i "7s/.*/ sha256 \"$SHA256_AMD64\"/" Casks/ipatool.rb 207 | sed -i "8s/.*/ url \"https:\/\/github.com\/majd\/ipatool\/releases\/download\/v${{ needs.get_version.outputs.version }}\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz\"/" Casks/ipatool.rb 208 | sed -i "9s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64\", target: \"ipatool\"/" Casks/ipatool.rb 209 | sed -i "12s/.*/ version \"${{ needs.get_version.outputs.version }}\"/" Casks/ipatool.rb 210 | git config --local user.name ${{ secrets.GH_NAME }} 211 | git config --local user.email ${{ secrets.GH_EMAIL }} 212 | git add Casks/ipatool.rb 213 | git commit -m "Update ipatool to v${{ needs.get_version.outputs.version }}" 214 | git push "https://${{ secrets.GH_TOKEN }}@github.com/${{ secrets.HOMEBREW_REPO }}.git" --set-upstream "main" 215 | env: 216 | SHA256_ARM64: ${{ steps.sha256.outputs.sha256_arm64 }} 217 | SHA256_AMD64: ${{ steps.sha256.outputs.sha256_amd64 }} -------------------------------------------------------------------------------- /.github/workflows/unit-tests.yml: -------------------------------------------------------------------------------- 1 | name: Unit Tests 2 | 3 | on: 4 | pull_request: 5 | branches: 6 | - main 7 | 8 | jobs: 9 | run_tests: 10 | name: Run tests 11 | runs-on: ubuntu-latest 12 | steps: 13 | - uses: actions/checkout@v2 14 | - uses: actions/setup-go@v3 15 | with: 16 | go-version: '1.19.3' 17 | cache: true 18 | - run: go generate github.com/majd/ipatool/... 19 | - run: go test -v github.com/majd/ipatool/... 20 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .AppleDouble 3 | .LSOverride 4 | .vscode/ 5 | .idea/ 6 | **/*_mock.go -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | linters: 2 | enable: 3 | - errcheck 4 | - govet 5 | - goimports 6 | - gofmt 7 | - unused 8 | - ginkgolinter 9 | - godot 10 | - godox 11 | - importas 12 | - nlreturn 13 | - nonamedreturns 14 | - prealloc 15 | - predeclared 16 | - tenv 17 | - unconvert 18 | - unparam 19 | - usestdlibvars 20 | - wastedassign 21 | - wrapcheck 22 | - wsl 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021 Majd Alfhaily 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # IPATool 2 | 3 | [![Release](https://img.shields.io/github/release/majd/ipatool.svg?label=Release)](https://GitHub.com/majd/ipatool/releases/) 4 | [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/majd/ipatool/blob/main/LICENSE) 5 | 6 | `ipatool` is a command line tool that allows you to search for iOS apps on the [App Store](https://apps.apple.com) and download a copy of the app package, known as an _ipa_ file. 7 | 8 | ![Demo](./demo.gif) 9 | 10 | - [Requirements](#requirements) 11 | - [Installation](#installation) 12 | - [Manual](#manual) 13 | - [Package Manager (macOS)](#package-manager-macos) 14 | - [Usage](#usage) 15 | - [Compiling](#compiling) 16 | - [License](#license) 17 | - [Releases](https://github.com/majd/ipatool/releases) 18 | - [FAQ](https://github.com/majd/ipatool/wiki/FAQ) 19 | 20 | ## Requirements 21 | - Supported operating system (Windows, Linux or macOS). 22 | - Apple ID set up to use the App Store. 23 | 24 | ## Installation 25 | 26 | ### Manual 27 | 28 | You can grab the latest version of `ipatool` from [GitHub releases](https://github.com/majd/ipatool/releases). 29 | 30 | ### Package Manager (macOS) 31 | 32 | You can install `ipatool` using [Homebrew](https://brew.sh). 33 | 34 | ```shell 35 | $ brew tap majd/repo 36 | $ brew install ipatool 37 | ``` 38 | 39 | ## Usage 40 | 41 | To authenticate with the App Store, use the `auth` command. 42 | 43 | ``` 44 | Authenticate with the App Store 45 | 46 | Usage: 47 | ipatool auth [command] 48 | 49 | Available Commands: 50 | info Show current account info 51 | login Login to the App Store 52 | revoke Revoke your App Store credentials 53 | 54 | Flags: 55 | -h, --help help for auth 56 | 57 | Global Flags: 58 | --format format sets output format for command; can be 'text', 'json' (default text) 59 | --non-interactive run in non-interactive session 60 | --verbose enables verbose logs 61 | 62 | Use "ipatool auth [command] --help" for more information about a command. 63 | ``` 64 | 65 | To search for apps on the App Store, use the `search` command. 66 | 67 | ``` 68 | Search for iOS apps available on the App Store 69 | 70 | Usage: 71 | ipatool search [flags] 72 | 73 | Flags: 74 | -h, --help help for search 75 | -l, --limit int maximum amount of search results to retrieve (default 5) 76 | 77 | Global Flags: 78 | --format format sets output format for command; can be 'text', 'json' (default text) 79 | --non-interactive run in non-interactive session 80 | --verbose enables verbose logs 81 | ``` 82 | 83 | To obtain a license for an app, use the `purchase` command. 84 | 85 | ``` 86 | Obtain a license for the app from the App Store 87 | 88 | Usage: 89 | ipatool purchase [flags] 90 | 91 | Flags: 92 | -b, --bundle-identifier string Bundle identifier of the target iOS app (required) 93 | -h, --help help for purchase 94 | 95 | Global Flags: 96 | --format format sets output format for command; can be 'text', 'json' (default text) 97 | --non-interactive run in non-interactive session 98 | --verbose enables verbose logs 99 | ``` 100 | 101 | To download a copy of the ipa file, use the `download` command. 102 | 103 | ``` 104 | Download (encrypted) iOS app packages from the App Store 105 | 106 | Usage: 107 | ipatool download [flags] 108 | 109 | Flags: 110 | -i, --app-id int ID of the target iOS app (required) 111 | -b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID) 112 | -h, --help help for download 113 | -o, --output string The destination path of the downloaded app package 114 | --purchase Obtain a license for the app if needed 115 | 116 | Global Flags: 117 | --format format sets output format for command; can be 'text', 'json' (default text) 118 | --keychain-passphrase string passphrase for unlocking keychain 119 | --non-interactive run in non-interactive session 120 | --verbose enables verbose logs 121 | ``` 122 | 123 | **Note:** the tool runs in interactive mode by default. Use the `--non-interactive` flag 124 | if running in an automated environment. 125 | 126 | ## Compiling 127 | 128 | The tool can be compiled using the Go toolchain. 129 | 130 | ```shell 131 | $ go build -o ipatool 132 | ``` 133 | 134 | Unit tests can be executed with the following commands. 135 | 136 | ```shell 137 | $ go generate github.com/majd/ipatool/... 138 | $ go test -v github.com/majd/ipatool/... 139 | ``` 140 | 141 | ## License 142 | 143 | IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE). 144 | -------------------------------------------------------------------------------- /cmd/auth.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "bufio" 5 | "errors" 6 | "fmt" 7 | "os" 8 | "strings" 9 | "time" 10 | 11 | "github.com/avast/retry-go" 12 | "github.com/majd/ipatool/v2/pkg/appstore" 13 | "github.com/majd/ipatool/v2/pkg/util" 14 | "github.com/spf13/cobra" 15 | "golang.org/x/term" 16 | ) 17 | 18 | func authCmd() *cobra.Command { 19 | cmd := &cobra.Command{ 20 | Use: "auth", 21 | Short: "Authenticate with the App Store", 22 | } 23 | 24 | cmd.AddCommand(loginCmd()) 25 | cmd.AddCommand(infoCmd()) 26 | cmd.AddCommand(revokeCmd()) 27 | 28 | return cmd 29 | } 30 | 31 | func loginCmd() *cobra.Command { 32 | promptForAuthCode := func() (string, error) { 33 | authCode, err := bufio.NewReader(os.Stdin).ReadString('\n') 34 | if err != nil { 35 | return "", fmt.Errorf("failed to read string: %w", err) 36 | } 37 | 38 | authCode = strings.Trim(authCode, "\n") 39 | authCode = strings.Trim(authCode, "\r") 40 | 41 | return authCode, nil 42 | } 43 | 44 | var email, password, authCode string 45 | 46 | cmd := &cobra.Command{ 47 | Use: "login", 48 | Short: "Login to the App Store", 49 | RunE: func(cmd *cobra.Command, args []string) error { 50 | interactive := cmd.Context().Value("interactive").(bool) 51 | 52 | if password == "" && !interactive { 53 | return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") 54 | } 55 | 56 | if password == "" && interactive { 57 | dependencies.Logger.Log().Msg("enter password:") 58 | 59 | bytes, err := term.ReadPassword(int(os.Stdin.Fd())) 60 | if err != nil { 61 | return fmt.Errorf("failed to read password: %w", err) 62 | } 63 | password = string(bytes) 64 | } 65 | 66 | var lastErr error 67 | 68 | // nolint:wrapcheck 69 | return retry.Do(func() error { 70 | if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive { 71 | dependencies.Logger.Log().Msg("enter 2FA code:") 72 | 73 | var err error 74 | authCode, err = promptForAuthCode() 75 | if err != nil { 76 | return fmt.Errorf("failed to read auth code: %w", err) 77 | } 78 | } 79 | 80 | dependencies.Logger.Verbose(). 81 | Str("password", password). 82 | Str("email", email). 83 | Str("authCode", util.IfEmpty(authCode, "")). 84 | Msg("logging in") 85 | 86 | output, err := dependencies.AppStore.Login(appstore.LoginInput{ 87 | Email: email, 88 | Password: password, 89 | AuthCode: authCode, 90 | }) 91 | if err != nil { 92 | if errors.Is(err, appstore.ErrAuthCodeRequired) && !interactive { 93 | dependencies.Logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag") 94 | 95 | return nil 96 | } 97 | 98 | return err 99 | } 100 | 101 | dependencies.Logger.Log(). 102 | Str("name", output.Account.Name). 103 | Str("email", output.Account.Email). 104 | Bool("success", true). 105 | Send() 106 | 107 | return nil 108 | }, 109 | retry.LastErrorOnly(true), 110 | retry.DelayType(retry.FixedDelay), 111 | retry.Delay(time.Millisecond), 112 | retry.Attempts(2), 113 | retry.RetryIf(func(err error) bool { 114 | lastErr = err 115 | 116 | return errors.Is(err, appstore.ErrAuthCodeRequired) 117 | }), 118 | ) 119 | }, 120 | } 121 | 122 | cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID (required)") 123 | cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)") 124 | cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID") 125 | 126 | _ = cmd.MarkFlagRequired("email") 127 | 128 | return cmd 129 | } 130 | 131 | // nolint:wrapcheck 132 | func infoCmd() *cobra.Command { 133 | return &cobra.Command{ 134 | Use: "info", 135 | Short: "Show current account info", 136 | RunE: func(cmd *cobra.Command, args []string) error { 137 | output, err := dependencies.AppStore.AccountInfo() 138 | if err != nil { 139 | return err 140 | } 141 | 142 | dependencies.Logger.Log(). 143 | Str("name", output.Account.Name). 144 | Str("email", output.Account.Email). 145 | Bool("success", true). 146 | Send() 147 | 148 | return nil 149 | }, 150 | } 151 | } 152 | 153 | // nolint:wrapcheck 154 | func revokeCmd() *cobra.Command { 155 | return &cobra.Command{ 156 | Use: "revoke", 157 | Short: "Revoke your App Store credentials", 158 | RunE: func(cmd *cobra.Command, args []string) error { 159 | err := dependencies.AppStore.Revoke() 160 | if err != nil { 161 | return err 162 | } 163 | 164 | dependencies.Logger.Log().Bool("success", true).Send() 165 | 166 | return nil 167 | }, 168 | } 169 | } 170 | -------------------------------------------------------------------------------- /cmd/common.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | "io" 7 | "os" 8 | "path/filepath" 9 | "strings" 10 | 11 | "github.com/99designs/keyring" 12 | cookiejar "github.com/juju/persistent-cookiejar" 13 | "github.com/majd/ipatool/v2/pkg/appstore" 14 | "github.com/majd/ipatool/v2/pkg/http" 15 | "github.com/majd/ipatool/v2/pkg/keychain" 16 | "github.com/majd/ipatool/v2/pkg/log" 17 | "github.com/majd/ipatool/v2/pkg/util" 18 | "github.com/majd/ipatool/v2/pkg/util/machine" 19 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 20 | "github.com/rs/zerolog" 21 | "github.com/spf13/cobra" 22 | "golang.org/x/term" 23 | ) 24 | 25 | var dependencies = Dependencies{} 26 | var keychainPassphrase string 27 | 28 | type Dependencies struct { 29 | Logger log.Logger 30 | OS operatingsystem.OperatingSystem 31 | Machine machine.Machine 32 | CookieJar http.CookieJar 33 | Keychain keychain.Keychain 34 | AppStore appstore.AppStore 35 | } 36 | 37 | // newLogger returns a new logger instance. 38 | func newLogger(format OutputFormat, verbose bool) log.Logger { 39 | var writer io.Writer 40 | 41 | switch format { 42 | case OutputFormatJSON: 43 | writer = zerolog.SyncWriter(os.Stdout) 44 | case OutputFormatText: 45 | writer = log.NewWriter() 46 | } 47 | 48 | return log.NewLogger(log.Args{ 49 | Verbose: verbose, 50 | Writer: writer, 51 | }, 52 | ) 53 | } 54 | 55 | // newCookieJar returns a new cookie jar instance. 56 | func newCookieJar(machine machine.Machine) http.CookieJar { 57 | return util.Must(cookiejar.New(&cookiejar.Options{ 58 | Filename: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName), 59 | })) 60 | } 61 | 62 | // newKeychain returns a new keychain instance. 63 | func newKeychain(machine machine.Machine, logger log.Logger, interactive bool) keychain.Keychain { 64 | ring := util.Must(keyring.Open(keyring.Config{ 65 | AllowedBackends: []keyring.BackendType{ 66 | keyring.KeychainBackend, 67 | keyring.SecretServiceBackend, 68 | keyring.FileBackend, 69 | }, 70 | ServiceName: KeychainServiceName, 71 | FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName), 72 | FilePasswordFunc: func(s string) (string, error) { 73 | if keychainPassphrase == "" && !interactive { 74 | return "", errors.New("keychain passphrase is required when not running in interactive mode; use the \"--keychain-passphrase\" flag") 75 | } 76 | 77 | if keychainPassphrase != "" { 78 | return keychainPassphrase, nil 79 | } 80 | 81 | path := strings.Split(s, " unlock ")[1] 82 | logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path) 83 | bytes, err := term.ReadPassword(int(os.Stdin.Fd())) 84 | if err != nil { 85 | return "", fmt.Errorf("failed to read password: %w", err) 86 | } 87 | 88 | password := string(bytes) 89 | password = strings.Trim(password, "\n") 90 | password = strings.Trim(password, "\r") 91 | 92 | return password, nil 93 | }, 94 | })) 95 | 96 | return keychain.New(keychain.Args{Keyring: ring}) 97 | } 98 | 99 | // initWithCommand initializes the dependencies of the command. 100 | func initWithCommand(cmd *cobra.Command) { 101 | verbose := cmd.Flag("verbose").Value.String() == "true" 102 | interactive, _ := cmd.Context().Value("interactive").(bool) 103 | format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String())) 104 | 105 | dependencies.Logger = newLogger(format, verbose) 106 | dependencies.OS = operatingsystem.New() 107 | dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) 108 | dependencies.CookieJar = newCookieJar(dependencies.Machine) 109 | dependencies.Keychain = newKeychain(dependencies.Machine, dependencies.Logger, interactive) 110 | dependencies.AppStore = appstore.NewAppStore(appstore.Args{ 111 | CookieJar: dependencies.CookieJar, 112 | OperatingSystem: dependencies.OS, 113 | Keychain: dependencies.Keychain, 114 | Machine: dependencies.Machine, 115 | }) 116 | 117 | util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine)) 118 | } 119 | 120 | // createConfigDirectory creates the configuration directory for the CLI tool, if needed. 121 | func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error { 122 | configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName) 123 | _, err := os.Stat(configDirectoryPath) 124 | 125 | if err != nil && os.IsNotExist(err) { 126 | err = os.MkdirAll(configDirectoryPath, 0700) 127 | if err != nil { 128 | return fmt.Errorf("failed to create config directory: %w", err) 129 | } 130 | } else if err != nil { 131 | return fmt.Errorf("could not read metadata: %w", err) 132 | } 133 | 134 | return nil 135 | } 136 | -------------------------------------------------------------------------------- /cmd/constants.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | const ( 4 | ConfigDirectoryName = ".ipatool" 5 | CookieJarFileName = "cookies" 6 | KeychainServiceName = "ipatool-auth.service" 7 | ) 8 | -------------------------------------------------------------------------------- /cmd/download.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "os" 6 | "time" 7 | 8 | "github.com/avast/retry-go" 9 | "github.com/majd/ipatool/v2/pkg/appstore" 10 | "github.com/schollz/progressbar/v3" 11 | "github.com/spf13/cobra" 12 | ) 13 | 14 | // nolint:wrapcheck 15 | func downloadCmd() *cobra.Command { 16 | var ( 17 | acquireLicense bool 18 | outputPath string 19 | appID int64 20 | bundleID string 21 | ) 22 | 23 | cmd := &cobra.Command{ 24 | Use: "download", 25 | Short: "Download (encrypted) iOS app packages from the App Store", 26 | RunE: func(cmd *cobra.Command, args []string) error { 27 | if appID == 0 && bundleID == "" { 28 | return errors.New("either the app ID or the bundle identifier must be specified") 29 | } 30 | 31 | var lastErr error 32 | var acc appstore.Account 33 | var purchased bool = false 34 | 35 | return retry.Do(func() error { 36 | infoResult, err := dependencies.AppStore.AccountInfo() 37 | if err != nil { 38 | return err 39 | } 40 | 41 | acc = infoResult.Account 42 | 43 | if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { 44 | loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) 45 | if err != nil { 46 | return err 47 | } 48 | 49 | acc = loginResult.Account 50 | } 51 | 52 | app := appstore.App{ID: appID} 53 | if bundleID != "" { 54 | lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) 55 | if err != nil { 56 | return err 57 | } 58 | 59 | app = lookupResult.App 60 | } 61 | 62 | if errors.Is(lastErr, appstore.ErrLicenseRequired) { 63 | err := dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: app}) 64 | if err != nil { 65 | return err 66 | } 67 | purchased = true 68 | dependencies.Logger.Verbose(). 69 | Bool("success", true). 70 | Msg("purchase") 71 | } 72 | 73 | interactive, _ := cmd.Context().Value("interactive").(bool) 74 | var progress *progressbar.ProgressBar 75 | if interactive { 76 | progress = progressbar.NewOptions64(1, 77 | progressbar.OptionSetDescription("downloading"), 78 | progressbar.OptionSetWriter(os.Stdout), 79 | progressbar.OptionShowBytes(true), 80 | progressbar.OptionSetWidth(20), 81 | progressbar.OptionFullWidth(), 82 | progressbar.OptionThrottle(65*time.Millisecond), 83 | progressbar.OptionShowCount(), 84 | progressbar.OptionClearOnFinish(), 85 | progressbar.OptionSpinnerType(14), 86 | progressbar.OptionSetRenderBlankState(true), 87 | progressbar.OptionSetElapsedTime(false), 88 | progressbar.OptionSetPredictTime(false), 89 | ) 90 | } 91 | 92 | out, err := dependencies.AppStore.Download(appstore.DownloadInput{Account: acc, App: app, OutputPath: outputPath, Progress: progress}) 93 | if err != nil { 94 | return err 95 | } 96 | 97 | err = dependencies.AppStore.ReplicateSinf(appstore.ReplicateSinfInput{Sinfs: out.Sinfs, PackagePath: out.DestinationPath}) 98 | if err != nil { 99 | return err 100 | } 101 | 102 | dependencies.Logger.Log(). 103 | Str("output", out.DestinationPath). 104 | Bool("purchased", purchased). 105 | Bool("success", true). 106 | Send() 107 | 108 | return nil 109 | }, 110 | retry.LastErrorOnly(true), 111 | retry.DelayType(retry.FixedDelay), 112 | retry.Delay(time.Millisecond), 113 | retry.Attempts(2), 114 | retry.RetryIf(func(err error) bool { 115 | lastErr = err 116 | 117 | if errors.Is(err, appstore.ErrPasswordTokenExpired) { 118 | return true 119 | } 120 | 121 | if errors.Is(err, appstore.ErrLicenseRequired) && acquireLicense { 122 | return true 123 | } 124 | 125 | return false 126 | }), 127 | ) 128 | }, 129 | } 130 | 131 | cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") 132 | cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") 133 | cmd.Flags().StringVarP(&outputPath, "output", "o", "", "The destination path of the downloaded app package") 134 | cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed") 135 | 136 | return cmd 137 | } 138 | -------------------------------------------------------------------------------- /cmd/output_format.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/thediveo/enumflag/v2" 7 | ) 8 | 9 | type OutputFormat enumflag.Flag 10 | 11 | const ( 12 | OutputFormatText OutputFormat = iota 13 | OutputFormatJSON 14 | ) 15 | 16 | func OutputFormatFromString(value string) (OutputFormat, error) { 17 | switch value { 18 | case "json": 19 | return OutputFormatJSON, nil 20 | case "text": 21 | return OutputFormatText, nil 22 | default: 23 | return OutputFormatJSON, fmt.Errorf("invalid output format '%s'", value) 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /cmd/purchase.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "time" 6 | 7 | "github.com/avast/retry-go" 8 | "github.com/majd/ipatool/v2/pkg/appstore" 9 | "github.com/spf13/cobra" 10 | ) 11 | 12 | // nolint:wrapcheck 13 | func purchaseCmd() *cobra.Command { 14 | var bundleID string 15 | 16 | cmd := &cobra.Command{ 17 | Use: "purchase", 18 | Short: "Obtain a license for the app from the App Store", 19 | RunE: func(cmd *cobra.Command, args []string) error { 20 | var lastErr error 21 | var acc appstore.Account 22 | 23 | return retry.Do(func() error { 24 | infoResult, err := dependencies.AppStore.AccountInfo() 25 | if err != nil { 26 | return err 27 | } 28 | 29 | acc = infoResult.Account 30 | 31 | if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { 32 | loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) 33 | if err != nil { 34 | return err 35 | } 36 | 37 | acc = loginResult.Account 38 | } 39 | 40 | lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) 41 | if err != nil { 42 | return err 43 | } 44 | 45 | err = dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App}) 46 | if err != nil { 47 | return err 48 | } 49 | 50 | dependencies.Logger.Log().Bool("success", true).Send() 51 | 52 | return nil 53 | }, 54 | retry.LastErrorOnly(true), 55 | retry.DelayType(retry.FixedDelay), 56 | retry.Delay(time.Millisecond), 57 | retry.Attempts(2), 58 | retry.RetryIf(func(err error) bool { 59 | lastErr = err 60 | 61 | return errors.Is(err, appstore.ErrPasswordTokenExpired) 62 | }), 63 | ) 64 | }, 65 | } 66 | 67 | cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "Bundle identifier of the target iOS app (required)") 68 | _ = cmd.MarkFlagRequired("bundle-identifier") 69 | 70 | return cmd 71 | } 72 | -------------------------------------------------------------------------------- /cmd/root.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "errors" 5 | "reflect" 6 | 7 | "github.com/majd/ipatool/v2/pkg/appstore" 8 | "github.com/spf13/cobra" 9 | "github.com/thediveo/enumflag/v2" 10 | "golang.org/x/net/context" 11 | ) 12 | 13 | var version = "dev" 14 | 15 | func rootCmd() *cobra.Command { 16 | var ( 17 | verbose bool 18 | nonInteractive bool 19 | format OutputFormat 20 | ) 21 | 22 | cmd := &cobra.Command{ 23 | Use: "ipatool", 24 | Short: "A cli tool for interacting with Apple's ipa files", 25 | SilenceErrors: true, 26 | SilenceUsage: true, 27 | Version: version, 28 | PersistentPreRun: func(cmd *cobra.Command, args []string) { 29 | ctx := context.WithValue(context.Background(), "interactive", !nonInteractive) 30 | cmd.SetContext(ctx) 31 | initWithCommand(cmd) 32 | }, 33 | } 34 | 35 | cmd.PersistentFlags().VarP( 36 | enumflag.New(&format, "format", map[OutputFormat][]string{ 37 | OutputFormatText: {"text"}, 38 | OutputFormatJSON: {"json"}, 39 | }, enumflag.EnumCaseSensitive), "format", "", "sets output format for command; can be 'text', 'json'") 40 | cmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enables verbose logs") 41 | cmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "", false, "run in non-interactive session") 42 | cmd.PersistentFlags().StringVar(&keychainPassphrase, "keychain-passphrase", "", "passphrase for unlocking keychain") 43 | 44 | cmd.AddCommand(authCmd()) 45 | cmd.AddCommand(downloadCmd()) 46 | cmd.AddCommand(purchaseCmd()) 47 | cmd.AddCommand(searchCmd()) 48 | 49 | return cmd 50 | } 51 | 52 | // Execute runs the program and returns the appropriate exit status code. 53 | func Execute() int { 54 | cmd := rootCmd() 55 | err := cmd.Execute() 56 | 57 | if err != nil { 58 | if reflect.ValueOf(dependencies).IsZero() { 59 | initWithCommand(cmd) 60 | } 61 | 62 | var appstoreErr *appstore.Error 63 | if errors.As(err, &appstoreErr) { 64 | dependencies.Logger.Verbose().Stack(). 65 | Err(err). 66 | Interface("metadata", appstoreErr.Metadata). 67 | Send() 68 | } else { 69 | dependencies.Logger.Verbose().Stack().Err(err).Send() 70 | } 71 | 72 | dependencies.Logger.Error(). 73 | Err(err). 74 | Bool("success", false). 75 | Send() 76 | 77 | return 1 78 | } 79 | 80 | return 0 81 | } 82 | -------------------------------------------------------------------------------- /cmd/search.go: -------------------------------------------------------------------------------- 1 | package cmd 2 | 3 | import ( 4 | "github.com/majd/ipatool/v2/pkg/appstore" 5 | "github.com/spf13/cobra" 6 | ) 7 | 8 | // nolint:wrapcheck 9 | func searchCmd() *cobra.Command { 10 | var limit int64 11 | 12 | cmd := &cobra.Command{ 13 | Use: "search ", 14 | Short: "Search for iOS apps available on the App Store", 15 | Args: cobra.ExactArgs(1), 16 | RunE: func(cmd *cobra.Command, args []string) error { 17 | infoResult, err := dependencies.AppStore.AccountInfo() 18 | if err != nil { 19 | return err 20 | } 21 | 22 | output, err := dependencies.AppStore.Search(appstore.SearchInput{ 23 | Account: infoResult.Account, 24 | Term: args[0], 25 | Limit: limit, 26 | }) 27 | if err != nil { 28 | return err 29 | } 30 | 31 | dependencies.Logger.Log(). 32 | Int("count", output.Count). 33 | Array("apps", appstore.Apps(output.Results)). 34 | Send() 35 | 36 | return nil 37 | }, 38 | } 39 | 40 | cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve") 41 | 42 | return cmd 43 | } 44 | -------------------------------------------------------------------------------- /demo.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/majd/ipatool/bbf37174569012fbd1cb9c7f7c4636fd82073ca6/demo.gif -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/majd/ipatool/v2 2 | 3 | go 1.19 4 | 5 | require ( 6 | github.com/99designs/keyring v1.2.1 7 | github.com/avast/retry-go v3.0.0+incompatible 8 | github.com/juju/persistent-cookiejar v1.0.0 9 | github.com/onsi/ginkgo/v2 v2.5.0 10 | github.com/onsi/gomega v1.24.0 11 | github.com/rs/zerolog v1.28.0 12 | github.com/schollz/progressbar/v3 v3.13.1 13 | github.com/spf13/cobra v1.6.1 14 | github.com/thediveo/enumflag/v2 v2.0.1 15 | go.uber.org/mock v0.4.0 16 | golang.org/x/net v0.38.0 17 | golang.org/x/term v0.30.0 18 | howett.net/plist v1.0.0 19 | ) 20 | 21 | require ( 22 | github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect 23 | github.com/danieljoos/wincred v1.1.2 // indirect 24 | github.com/dvsekhvalnov/jose2go v1.6.0 // indirect 25 | github.com/go-logr/logr v1.2.3 // indirect 26 | github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect 27 | github.com/google/go-cmp v0.6.0 // indirect 28 | github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect 29 | github.com/inconshreveable/mousetrap v1.0.1 // indirect 30 | github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect 31 | github.com/mattn/go-colorable v0.1.13 // indirect 32 | github.com/mattn/go-isatty v0.0.17 // indirect 33 | github.com/mattn/go-runewidth v0.0.14 // indirect 34 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect 35 | github.com/mtibben/percent v0.2.1 // indirect 36 | github.com/pkg/errors v0.9.1 // indirect 37 | github.com/rivo/uniseg v0.2.0 // indirect 38 | github.com/spf13/pflag v1.0.5 // indirect 39 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect 40 | golang.org/x/mod v0.17.0 // indirect 41 | golang.org/x/sys v0.31.0 // indirect 42 | golang.org/x/text v0.23.0 // indirect 43 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect 44 | gopkg.in/errgo.v1 v1.0.1 // indirect 45 | gopkg.in/retry.v1 v1.0.3 // indirect 46 | gopkg.in/yaml.v3 v3.0.1 // indirect 47 | ) 48 | -------------------------------------------------------------------------------- /go.sum: -------------------------------------------------------------------------------- 1 | github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= 2 | github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= 3 | github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= 4 | github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= 5 | github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= 6 | github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= 7 | github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= 8 | github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= 9 | github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= 10 | github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= 11 | github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 12 | github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= 13 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= 14 | github.com/dvsekhvalnov/jose2go v1.6.0 h1:Y9gnSnP4qEI0+/uQkHvFXeD2PLPJeXEL+ySMEA2EjTY= 15 | github.com/dvsekhvalnov/jose2go v1.6.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= 16 | github.com/frankban/quicktest v1.2.2 h1:xfmOhhoH5fGPgbEAlhLpJH9p0z/0Qizio9osmvn9IUY= 17 | github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= 18 | github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= 19 | github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= 20 | github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= 21 | github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= 22 | github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= 23 | github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= 24 | github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= 25 | github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= 26 | github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= 27 | github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= 28 | github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= 29 | github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= 30 | github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= 31 | github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 32 | github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las= 33 | github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk= 34 | github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ= 35 | github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w= 36 | github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= 37 | github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= 38 | github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= 39 | github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= 40 | github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= 41 | github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= 42 | github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= 43 | github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= 44 | github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= 45 | github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= 46 | github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 47 | github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= 48 | github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= 49 | github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= 50 | github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= 51 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= 52 | github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= 53 | github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= 54 | github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= 55 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= 56 | github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= 57 | github.com/onsi/ginkgo/v2 v2.5.0 h1:TRtrvv2vdQqzkwrQ1ke6vtXf7IK34RBUJafIy1wMwls= 58 | github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= 59 | github.com/onsi/gomega v1.24.0 h1:+0glovB9Jd6z3VR+ScSwQqXVTIfJcGA9UBM8yzQxhqg= 60 | github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= 61 | github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= 62 | github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= 63 | github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= 64 | github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= 65 | github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= 66 | github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= 67 | github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= 68 | github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= 69 | github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= 70 | github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= 71 | github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= 72 | github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= 73 | github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= 74 | github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= 75 | github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= 76 | github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= 77 | github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= 78 | github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= 79 | github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= 80 | github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= 81 | github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= 82 | github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= 83 | github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= 84 | github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= 85 | github.com/thediveo/enumflag/v2 v2.0.1 h1:2bmWZPD2uSARDsOjXIdLRlNcYBFNF9xX0RNUNF2vKic= 86 | github.com/thediveo/enumflag/v2 v2.0.1/go.mod h1:SyxyCNvv0QeRtZ7fjuaUz4FRLC3cWuDiD7QdORU0MGg= 87 | go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= 88 | go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= 89 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= 90 | golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= 91 | golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= 92 | golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= 93 | golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= 94 | golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= 95 | golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= 96 | golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 97 | golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 98 | golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 99 | golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 100 | golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= 101 | golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= 102 | golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= 103 | golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= 104 | golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= 105 | golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= 106 | golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= 107 | golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= 108 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= 109 | golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= 110 | google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= 111 | gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 112 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= 113 | gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= 114 | gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= 115 | gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= 116 | gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= 117 | gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= 118 | gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= 119 | gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 120 | gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= 121 | gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= 122 | howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= 123 | howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= 124 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "os" 5 | 6 | "github.com/majd/ipatool/v2/cmd" 7 | ) 8 | 9 | func main() { 10 | os.Exit(cmd.Execute()) 11 | } 12 | -------------------------------------------------------------------------------- /pkg/appstore/account.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | type Account struct { 4 | Email string `json:"email,omitempty"` 5 | PasswordToken string `json:"passwordToken,omitempty"` 6 | DirectoryServicesID string `json:"directoryServicesIdentifier,omitempty"` 7 | Name string `json:"name,omitempty"` 8 | StoreFront string `json:"storeFront,omitempty"` 9 | Password string `json:"password,omitempty"` 10 | } 11 | -------------------------------------------------------------------------------- /pkg/appstore/app.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "github.com/rs/zerolog" 5 | ) 6 | 7 | type App struct { 8 | ID int64 `json:"trackId,omitempty"` 9 | BundleID string `json:"bundleId,omitempty"` 10 | Name string `json:"trackName,omitempty"` 11 | Version string `json:"version,omitempty"` 12 | Price float64 `json:"price,omitempty"` 13 | } 14 | 15 | type Apps []App 16 | 17 | func (apps Apps) MarshalZerologArray(a *zerolog.Array) { 18 | for _, app := range apps { 19 | a.Object(app) 20 | } 21 | } 22 | 23 | func (a App) MarshalZerologObject(event *zerolog.Event) { 24 | event. 25 | Int64("id", a.ID). 26 | Str("bundleID", a.BundleID). 27 | Str("name", a.Name). 28 | Str("version", a.Version). 29 | Float64("price", a.Price) 30 | } 31 | -------------------------------------------------------------------------------- /pkg/appstore/app_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "github.com/rs/zerolog" 10 | ) 11 | 12 | var _ = Describe("App", func() { 13 | It("marshals apps array", func() { 14 | apps := Apps{ 15 | { 16 | ID: 42, 17 | BundleID: "app.bundle.id", 18 | Name: "app name", 19 | Version: "1.0", 20 | Price: 0, 21 | }, 22 | { 23 | ID: 1, 24 | BundleID: "app.bundle.id2", 25 | Name: "app name2", 26 | Version: "2.0", 27 | Price: 0.99, 28 | }, 29 | } 30 | 31 | buffer := bytes.NewBuffer([]byte{}) 32 | logger := zerolog.New(buffer) 33 | event := logger.Log().Array("apps", apps) 34 | event.Send() 35 | 36 | var out map[string]interface{} 37 | err := json.Unmarshal(buffer.Bytes(), &out) 38 | Expect(err).ToNot(HaveOccurred()) 39 | Expect(out["apps"]).To(HaveLen(2)) 40 | }) 41 | 42 | It("marshalls app object", func() { 43 | app := App{ 44 | ID: 42, 45 | BundleID: "app.bundle.id", 46 | Name: "app name", 47 | Version: "1.0", 48 | Price: 0, 49 | } 50 | 51 | buffer := bytes.NewBuffer([]byte{}) 52 | logger := zerolog.New(buffer) 53 | event := logger.Log() 54 | app.MarshalZerologObject(event) 55 | event.Send() 56 | 57 | var out map[string]interface{} 58 | err := json.Unmarshal(buffer.Bytes(), &out) 59 | Expect(err).ToNot(HaveOccurred()) 60 | 61 | Expect(out["id"]).To(Equal(float64(42))) 62 | Expect(out["bundleID"]).To(Equal("app.bundle.id")) 63 | Expect(out["name"]).To(Equal("app name")) 64 | Expect(out["version"]).To(Equal("1.0")) 65 | Expect(out["price"]).To(Equal(float64(0))) 66 | }) 67 | 68 | It("formats ipa name correctly", func() { 69 | app := App{ 70 | ID: 42, 71 | BundleID: "app.bundle-id1", 72 | Name: " some app&symb.ols2 !!!", 73 | Version: "1.0", 74 | Price: 0, 75 | } 76 | 77 | Expect(fileName(app)).To(Equal("app.bundle-id1_42_1.0.ipa")) 78 | }) 79 | }) 80 | -------------------------------------------------------------------------------- /pkg/appstore/appstore.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "github.com/majd/ipatool/v2/pkg/http" 5 | "github.com/majd/ipatool/v2/pkg/keychain" 6 | "github.com/majd/ipatool/v2/pkg/util/machine" 7 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 8 | ) 9 | 10 | type AppStore interface { 11 | // Login authenticates with the App Store. 12 | Login(input LoginInput) (LoginOutput, error) 13 | // AccountInfo returns the information of the authenticated account. 14 | AccountInfo() (AccountInfoOutput, error) 15 | // Revoke revokes the active credentials. 16 | Revoke() error 17 | // Lookup looks apps up based on the specified bundle identifier. 18 | Lookup(input LookupInput) (LookupOutput, error) 19 | // Search searches the App Store for apps matching the specified term. 20 | Search(input SearchInput) (SearchOutput, error) 21 | // Purchase acquires a license for the desired app. 22 | // Note: only free apps are supported. 23 | Purchase(input PurchaseInput) error 24 | // Download downloads the IPA package from the App Store to the desired location. 25 | Download(input DownloadInput) (DownloadOutput, error) 26 | // ReplicateSinf replicates the sinf for the IPA package. 27 | ReplicateSinf(input ReplicateSinfInput) error 28 | } 29 | 30 | type appstore struct { 31 | keychain keychain.Keychain 32 | loginClient http.Client[loginResult] 33 | searchClient http.Client[searchResult] 34 | purchaseClient http.Client[purchaseResult] 35 | downloadClient http.Client[downloadResult] 36 | httpClient http.Client[interface{}] 37 | machine machine.Machine 38 | os operatingsystem.OperatingSystem 39 | } 40 | 41 | type Args struct { 42 | Keychain keychain.Keychain 43 | CookieJar http.CookieJar 44 | OperatingSystem operatingsystem.OperatingSystem 45 | Machine machine.Machine 46 | } 47 | 48 | func NewAppStore(args Args) AppStore { 49 | clientArgs := http.Args{ 50 | CookieJar: args.CookieJar, 51 | } 52 | 53 | return &appstore{ 54 | keychain: args.Keychain, 55 | loginClient: http.NewClient[loginResult](clientArgs), 56 | searchClient: http.NewClient[searchResult](clientArgs), 57 | purchaseClient: http.NewClient[purchaseResult](clientArgs), 58 | downloadClient: http.NewClient[downloadResult](clientArgs), 59 | httpClient: http.NewClient[interface{}](clientArgs), 60 | machine: args.Machine, 61 | os: args.OperatingSystem, 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_account_info.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "encoding/json" 5 | "fmt" 6 | ) 7 | 8 | type AccountInfoOutput struct { 9 | Account Account 10 | } 11 | 12 | func (t *appstore) AccountInfo() (AccountInfoOutput, error) { 13 | data, err := t.keychain.Get("account") 14 | if err != nil { 15 | return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err) 16 | } 17 | 18 | var acc Account 19 | 20 | err = json.Unmarshal(data, &acc) 21 | if err != nil { 22 | return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err) 23 | } 24 | 25 | return AccountInfoOutput{ 26 | Account: acc, 27 | }, nil 28 | } 29 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_account_info_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | 7 | "github.com/majd/ipatool/v2/pkg/keychain" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | var _ = Describe("AppStore (AccountInfo)", func() { 14 | var ( 15 | ctrl *gomock.Controller 16 | appstore AppStore 17 | mockKeychain *keychain.MockKeychain 18 | ) 19 | 20 | BeforeEach(func() { 21 | ctrl = gomock.NewController(GinkgoT()) 22 | mockKeychain = keychain.NewMockKeychain(ctrl) 23 | appstore = NewAppStore(Args{ 24 | Keychain: mockKeychain, 25 | }) 26 | }) 27 | 28 | AfterEach(func() { 29 | ctrl.Finish() 30 | }) 31 | 32 | When("keychain returns valid data", func() { 33 | const ( 34 | testEmail = "test-email" 35 | testName = "test-name" 36 | ) 37 | 38 | BeforeEach(func() { 39 | mockKeychain.EXPECT(). 40 | Get("account"). 41 | Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil) 42 | }) 43 | 44 | It("returns output", func() { 45 | out, err := appstore.AccountInfo() 46 | Expect(err).ToNot(HaveOccurred()) 47 | Expect(out.Account.Email).To(Equal(testEmail)) 48 | Expect(out.Account.Name).To(Equal(testName)) 49 | }) 50 | }) 51 | 52 | When("keychain returns error", func() { 53 | BeforeEach(func() { 54 | mockKeychain.EXPECT(). 55 | Get("account"). 56 | Return([]byte{}, errors.New("")) 57 | }) 58 | 59 | It("returns wrapped error", func() { 60 | _, err := appstore.AccountInfo() 61 | Expect(err).To(HaveOccurred()) 62 | }) 63 | }) 64 | 65 | When("keychain returns invalid data", func() { 66 | BeforeEach(func() { 67 | mockKeychain.EXPECT(). 68 | Get("account"). 69 | Return([]byte("..."), nil) 70 | }) 71 | 72 | It("fails to unmarshall JSON data", func() { 73 | _, err := appstore.AccountInfo() 74 | Expect(err).To(HaveOccurred()) 75 | }) 76 | }) 77 | }) 78 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_download.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "archive/zip" 5 | "errors" 6 | "fmt" 7 | "io" 8 | "os" 9 | "strconv" 10 | "strings" 11 | 12 | "github.com/majd/ipatool/v2/pkg/http" 13 | "github.com/schollz/progressbar/v3" 14 | "howett.net/plist" 15 | ) 16 | 17 | var ( 18 | ErrLicenseRequired = errors.New("license is required") 19 | ) 20 | 21 | type DownloadInput struct { 22 | Account Account 23 | App App 24 | OutputPath string 25 | Progress *progressbar.ProgressBar 26 | } 27 | 28 | type DownloadOutput struct { 29 | DestinationPath string 30 | Sinfs []Sinf 31 | } 32 | 33 | func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { 34 | destination, err := t.resolveDestinationPath(input.App, input.OutputPath) 35 | if err != nil { 36 | return DownloadOutput{}, fmt.Errorf("failed to resolve destination path: %w", err) 37 | } 38 | 39 | macAddr, err := t.machine.MacAddress() 40 | if err != nil { 41 | return DownloadOutput{}, fmt.Errorf("failed to get mac address: %w", err) 42 | } 43 | 44 | guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") 45 | 46 | req := t.downloadRequest(input.Account, input.App, guid) 47 | 48 | res, err := t.downloadClient.Send(req) 49 | if err != nil { 50 | return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) 51 | } 52 | 53 | if res.Data.FailureType == FailureTypePasswordTokenExpired { 54 | return DownloadOutput{}, ErrPasswordTokenExpired 55 | } 56 | 57 | if res.Data.FailureType == FailureTypeLicenseNotFound { 58 | return DownloadOutput{}, ErrLicenseRequired 59 | } 60 | 61 | if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { 62 | return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) 63 | } 64 | 65 | if res.Data.FailureType != "" { 66 | return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) 67 | } 68 | 69 | if len(res.Data.Items) == 0 { 70 | return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) 71 | } 72 | 73 | item := res.Data.Items[0] 74 | 75 | err = t.downloadFile(item.URL, fmt.Sprintf("%s.tmp", destination), input.Progress) 76 | if err != nil { 77 | return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err) 78 | } 79 | 80 | err = t.applyPatches(item, input.Account, fmt.Sprintf("%s.tmp", destination), destination) 81 | if err != nil { 82 | return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) 83 | } 84 | 85 | err = t.os.Remove(fmt.Sprintf("%s.tmp", destination)) 86 | if err != nil { 87 | return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err) 88 | } 89 | 90 | return DownloadOutput{ 91 | DestinationPath: destination, 92 | Sinfs: item.Sinfs, 93 | }, nil 94 | } 95 | 96 | type downloadItemResult struct { 97 | HashMD5 string `plist:"md5,omitempty"` 98 | URL string `plist:"URL,omitempty"` 99 | Sinfs []Sinf `plist:"sinfs,omitempty"` 100 | Metadata map[string]interface{} `plist:"metadata,omitempty"` 101 | } 102 | 103 | type downloadResult struct { 104 | FailureType string `plist:"failureType,omitempty"` 105 | CustomerMessage string `plist:"customerMessage,omitempty"` 106 | Items []downloadItemResult `plist:"songList,omitempty"` 107 | } 108 | 109 | func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar) error { 110 | req, err := t.httpClient.NewRequest("GET", src, nil) 111 | if err != nil { 112 | return fmt.Errorf("failed to create request: %w", err) 113 | } 114 | 115 | res, err := t.httpClient.Do(req) 116 | if err != nil { 117 | return fmt.Errorf("request failed: %w", err) 118 | } 119 | defer res.Body.Close() 120 | 121 | file, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) 122 | if err != nil { 123 | return fmt.Errorf("failed to open file: %w", err) 124 | } 125 | 126 | defer file.Close() 127 | 128 | if progress != nil { 129 | progress.ChangeMax64(res.ContentLength) 130 | _, err = io.Copy(io.MultiWriter(file, progress), res.Body) 131 | } else { 132 | _, err = io.Copy(file, res.Body) 133 | } 134 | 135 | if err != nil { 136 | return fmt.Errorf("failed to write file: %w", err) 137 | } 138 | 139 | return nil 140 | } 141 | 142 | func (*appstore) downloadRequest(acc Account, app App, guid string) http.Request { 143 | host := fmt.Sprintf("%s-%s", PrivateAppStoreAPIDomainPrefixWithoutAuthCode, PrivateAppStoreAPIDomain) 144 | 145 | return http.Request{ 146 | URL: fmt.Sprintf("https://%s%s?guid=%s", host, PrivateAppStoreAPIPathDownload, guid), 147 | Method: http.MethodPOST, 148 | ResponseFormat: http.ResponseFormatXML, 149 | Headers: map[string]string{ 150 | "Content-Type": "application/x-apple-plist", 151 | "iCloud-DSID": acc.DirectoryServicesID, 152 | "X-Dsid": acc.DirectoryServicesID, 153 | }, 154 | Payload: &http.XMLPayload{ 155 | Content: map[string]interface{}{ 156 | "creditDisplay": "", 157 | "guid": guid, 158 | "salableAdamId": app.ID, 159 | }, 160 | }, 161 | } 162 | } 163 | 164 | func fileName(app App) string { 165 | var parts []string 166 | 167 | if app.BundleID != "" { 168 | parts = append(parts, app.BundleID) 169 | } 170 | 171 | if app.ID != 0 { 172 | parts = append(parts, strconv.FormatInt(app.ID, 10)) 173 | } 174 | 175 | if app.Version != "" { 176 | parts = append(parts, app.Version) 177 | } 178 | 179 | return fmt.Sprintf("%s.ipa", strings.Join(parts, "_")) 180 | } 181 | 182 | func (t *appstore) resolveDestinationPath(app App, path string) (string, error) { 183 | file := fileName(app) 184 | 185 | if path == "" { 186 | workdir, err := t.os.Getwd() 187 | if err != nil { 188 | return "", fmt.Errorf("failed to get current directory: %w", err) 189 | } 190 | 191 | return fmt.Sprintf("%s/%s", workdir, file), nil 192 | } 193 | 194 | isDir, err := t.isDirectory(path) 195 | if err != nil { 196 | return "", fmt.Errorf("failed to determine whether path is a directory: %w", err) 197 | } 198 | 199 | if isDir { 200 | return fmt.Sprintf("%s/%s", path, file), nil 201 | } 202 | 203 | return path, nil 204 | } 205 | 206 | func (t *appstore) isDirectory(path string) (bool, error) { 207 | info, err := t.os.Stat(path) 208 | if err != nil && !os.IsNotExist(err) { 209 | return false, fmt.Errorf("failed to read file metadata: %w", err) 210 | } 211 | 212 | if info == nil { 213 | return false, nil 214 | } 215 | 216 | return info.IsDir(), nil 217 | } 218 | 219 | func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error { 220 | srcZip, err := zip.OpenReader(src) 221 | if err != nil { 222 | return fmt.Errorf("failed to open zip reader: %w", err) 223 | } 224 | defer srcZip.Close() 225 | 226 | dstFile, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) 227 | if err != nil { 228 | return fmt.Errorf("failed to open file: %w", err) 229 | } 230 | 231 | dstZip := zip.NewWriter(dstFile) 232 | defer dstZip.Close() 233 | 234 | err = t.replicateZip(srcZip, dstZip) 235 | if err != nil { 236 | return fmt.Errorf("failed to replicate zip: %w", err) 237 | } 238 | 239 | err = t.writeMetadata(item.Metadata, acc, dstZip) 240 | if err != nil { 241 | return fmt.Errorf("failed to write metadata: %w", err) 242 | } 243 | 244 | return nil 245 | } 246 | 247 | func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error { 248 | metadata["apple-id"] = acc.Email 249 | metadata["userName"] = acc.Email 250 | 251 | metadataFile, err := zip.Create("iTunesMetadata.plist") 252 | if err != nil { 253 | return fmt.Errorf("failed to create file: %w", err) 254 | } 255 | 256 | data, err := plist.Marshal(metadata, plist.BinaryFormat) 257 | if err != nil { 258 | return fmt.Errorf("failed to marshal data: %w", err) 259 | } 260 | 261 | _, err = metadataFile.Write(data) 262 | if err != nil { 263 | return fmt.Errorf("failed to write data: %w", err) 264 | } 265 | 266 | return nil 267 | } 268 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_download_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "archive/zip" 5 | "errors" 6 | "fmt" 7 | "io" 8 | gohttp "net/http" 9 | "os" 10 | "strings" 11 | 12 | "github.com/majd/ipatool/v2/pkg/http" 13 | "github.com/majd/ipatool/v2/pkg/keychain" 14 | "github.com/majd/ipatool/v2/pkg/util/machine" 15 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 16 | . "github.com/onsi/ginkgo/v2" 17 | . "github.com/onsi/gomega" 18 | "go.uber.org/mock/gomock" 19 | "howett.net/plist" 20 | ) 21 | 22 | var _ = Describe("AppStore (Download)", func() { 23 | var ( 24 | ctrl *gomock.Controller 25 | mockKeychain *keychain.MockKeychain 26 | mockDownloadClient *http.MockClient[downloadResult] 27 | mockPurchaseClient *http.MockClient[purchaseResult] 28 | mockLoginClient *http.MockClient[loginResult] 29 | mockHTTPClient *http.MockClient[interface{}] 30 | mockOS *operatingsystem.MockOperatingSystem 31 | mockMachine *machine.MockMachine 32 | as AppStore 33 | ) 34 | 35 | BeforeEach(func() { 36 | ctrl = gomock.NewController(GinkgoT()) 37 | mockKeychain = keychain.NewMockKeychain(ctrl) 38 | mockDownloadClient = http.NewMockClient[downloadResult](ctrl) 39 | mockLoginClient = http.NewMockClient[loginResult](ctrl) 40 | mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) 41 | mockHTTPClient = http.NewMockClient[interface{}](ctrl) 42 | mockOS = operatingsystem.NewMockOperatingSystem(ctrl) 43 | mockMachine = machine.NewMockMachine(ctrl) 44 | as = &appstore{ 45 | keychain: mockKeychain, 46 | loginClient: mockLoginClient, 47 | purchaseClient: mockPurchaseClient, 48 | downloadClient: mockDownloadClient, 49 | httpClient: mockHTTPClient, 50 | machine: mockMachine, 51 | os: mockOS, 52 | } 53 | }) 54 | 55 | AfterEach(func() { 56 | ctrl.Finish() 57 | }) 58 | 59 | When("fails to resolve output path", func() { 60 | BeforeEach(func() { 61 | mockOS.EXPECT(). 62 | Stat(gomock.Any()). 63 | Return(nil, errors.New("")) 64 | }) 65 | 66 | It("returns error", func() { 67 | _, err := as.Download(DownloadInput{ 68 | OutputPath: "test-out", 69 | }) 70 | Expect(err).To(HaveOccurred()) 71 | }) 72 | }) 73 | 74 | When("fails to read MAC address", func() { 75 | BeforeEach(func() { 76 | mockOS.EXPECT(). 77 | Getwd(). 78 | Return("", nil) 79 | 80 | mockMachine.EXPECT(). 81 | MacAddress(). 82 | Return("", errors.New("")) 83 | }) 84 | 85 | It("returns error", func() { 86 | _, err := as.Download(DownloadInput{}) 87 | Expect(err).To(HaveOccurred()) 88 | }) 89 | }) 90 | 91 | When("request fails", func() { 92 | BeforeEach(func() { 93 | mockOS.EXPECT(). 94 | Getwd(). 95 | Return("", nil) 96 | 97 | mockMachine.EXPECT(). 98 | MacAddress(). 99 | Return("", nil) 100 | 101 | mockDownloadClient.EXPECT(). 102 | Send(gomock.Any()). 103 | Return(http.Result[downloadResult]{}, errors.New("")) 104 | }) 105 | 106 | It("returns error", func() { 107 | _, err := as.Download(DownloadInput{}) 108 | Expect(err).To(HaveOccurred()) 109 | }) 110 | }) 111 | 112 | When("password token is expired", func() { 113 | BeforeEach(func() { 114 | mockOS.EXPECT(). 115 | Getwd(). 116 | Return("", nil) 117 | 118 | mockMachine.EXPECT(). 119 | MacAddress(). 120 | Return("", nil) 121 | 122 | mockDownloadClient.EXPECT(). 123 | Send(gomock.Any()). 124 | Return(http.Result[downloadResult]{ 125 | Data: downloadResult{ 126 | FailureType: FailureTypePasswordTokenExpired, 127 | }, 128 | }, nil) 129 | }) 130 | 131 | It("returns error", func() { 132 | _, err := as.Download(DownloadInput{}) 133 | Expect(err).To(HaveOccurred()) 134 | }) 135 | }) 136 | 137 | When("license is missing", func() { 138 | BeforeEach(func() { 139 | mockOS.EXPECT(). 140 | Getwd(). 141 | Return("", nil) 142 | 143 | mockMachine.EXPECT(). 144 | MacAddress(). 145 | Return("", nil) 146 | 147 | mockDownloadClient.EXPECT(). 148 | Send(gomock.Any()). 149 | Return(http.Result[downloadResult]{ 150 | Data: downloadResult{ 151 | FailureType: FailureTypeLicenseNotFound, 152 | }, 153 | }, nil) 154 | }) 155 | 156 | It("returns error", func() { 157 | _, err := as.Download(DownloadInput{}) 158 | Expect(err).To(HaveOccurred()) 159 | }) 160 | }) 161 | 162 | When("store API returns error", func() { 163 | BeforeEach(func() { 164 | mockOS.EXPECT(). 165 | Getwd(). 166 | Return("", nil) 167 | 168 | mockMachine.EXPECT(). 169 | MacAddress(). 170 | Return("", nil) 171 | }) 172 | 173 | When("response contains customer message", func() { 174 | BeforeEach(func() { 175 | mockDownloadClient.EXPECT(). 176 | Send(gomock.Any()). 177 | Return(http.Result[downloadResult]{ 178 | Data: downloadResult{ 179 | FailureType: "test-failure", 180 | CustomerMessage: errors.New("").Error(), 181 | }, 182 | }, nil) 183 | }) 184 | 185 | It("returns customer message as error", func() { 186 | _, err := as.Download(DownloadInput{}) 187 | Expect(err).To(HaveOccurred()) 188 | }) 189 | }) 190 | 191 | When("response does not contain customer message", func() { 192 | BeforeEach(func() { 193 | mockDownloadClient.EXPECT(). 194 | Send(gomock.Any()). 195 | Return(http.Result[downloadResult]{ 196 | Data: downloadResult{ 197 | FailureType: "test-failure", 198 | }, 199 | }, nil) 200 | }) 201 | 202 | It("returns generic error", func() { 203 | _, err := as.Download(DownloadInput{}) 204 | Expect(err).To(HaveOccurred()) 205 | }) 206 | }) 207 | }) 208 | 209 | When("store API returns no items", func() { 210 | BeforeEach(func() { 211 | mockOS.EXPECT(). 212 | Getwd(). 213 | Return("", nil) 214 | 215 | mockMachine.EXPECT(). 216 | MacAddress(). 217 | Return("", nil) 218 | 219 | mockDownloadClient.EXPECT(). 220 | Send(gomock.Any()). 221 | Return(http.Result[downloadResult]{ 222 | Data: downloadResult{ 223 | Items: []downloadItemResult{}, 224 | }, 225 | }, nil) 226 | }) 227 | 228 | It("returns error", func() { 229 | _, err := as.Download(DownloadInput{}) 230 | Expect(err).To(HaveOccurred()) 231 | }) 232 | }) 233 | 234 | When("fails to download file", func() { 235 | BeforeEach(func() { 236 | mockOS.EXPECT(). 237 | Getwd(). 238 | Return("", nil) 239 | 240 | mockMachine.EXPECT(). 241 | MacAddress(). 242 | Return("", nil) 243 | 244 | mockDownloadClient.EXPECT(). 245 | Send(gomock.Any()). 246 | Return(http.Result[downloadResult]{ 247 | Data: downloadResult{ 248 | Items: []downloadItemResult{{}}, 249 | }, 250 | }, nil) 251 | }) 252 | 253 | When("fails to create download request", func() { 254 | BeforeEach(func() { 255 | mockHTTPClient.EXPECT(). 256 | NewRequest("GET", gomock.Any(), nil). 257 | Return(nil, errors.New("")) 258 | }) 259 | 260 | It("returns error", func() { 261 | _, err := as.Download(DownloadInput{}) 262 | Expect(err).To(HaveOccurred()) 263 | }) 264 | }) 265 | 266 | When("request fails", func() { 267 | BeforeEach(func() { 268 | mockHTTPClient.EXPECT(). 269 | NewRequest("GET", gomock.Any(), nil). 270 | Return(nil, nil) 271 | 272 | mockHTTPClient.EXPECT(). 273 | Do(gomock.Any()). 274 | Return(nil, errors.New("")) 275 | }) 276 | 277 | It("returns error", func() { 278 | _, err := as.Download(DownloadInput{}) 279 | Expect(err).To(HaveOccurred()) 280 | }) 281 | }) 282 | 283 | When("fails to open file", func() { 284 | BeforeEach(func() { 285 | mockHTTPClient.EXPECT(). 286 | NewRequest("GET", gomock.Any(), nil). 287 | Return(nil, nil) 288 | 289 | mockHTTPClient.EXPECT(). 290 | Do(gomock.Any()). 291 | Return(&gohttp.Response{ 292 | Body: gohttp.NoBody, 293 | }, nil) 294 | 295 | mockOS.EXPECT(). 296 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 297 | Return(nil, errors.New("")) 298 | }) 299 | 300 | It("returns error", func() { 301 | _, err := as.Download(DownloadInput{}) 302 | Expect(err).To(HaveOccurred()) 303 | }) 304 | }) 305 | 306 | When("fails to write data to file", func() { 307 | BeforeEach(func() { 308 | mockHTTPClient.EXPECT(). 309 | NewRequest("GET", gomock.Any(), nil). 310 | Return(nil, nil) 311 | 312 | mockHTTPClient.EXPECT(). 313 | Do(gomock.Any()). 314 | Return(&gohttp.Response{ 315 | Body: io.NopCloser(strings.NewReader("ping")), 316 | }, nil) 317 | 318 | mockOS.EXPECT(). 319 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 320 | Return(nil, nil) 321 | }) 322 | 323 | It("returns error", func() { 324 | _, err := as.Download(DownloadInput{}) 325 | Expect(err).To(HaveOccurred()) 326 | }) 327 | }) 328 | }) 329 | 330 | When("successfully downloads file", func() { 331 | var testFile *os.File 332 | 333 | BeforeEach(func() { 334 | var err error 335 | testFile, err = os.CreateTemp("", "test_file") 336 | Expect(err).ToNot(HaveOccurred()) 337 | 338 | mockMachine.EXPECT(). 339 | MacAddress(). 340 | Return("", nil) 341 | 342 | mockDownloadClient.EXPECT(). 343 | Send(gomock.Any()). 344 | Return(http.Result[downloadResult]{ 345 | Data: downloadResult{ 346 | Items: []downloadItemResult{ 347 | { 348 | Metadata: map[string]interface{}{}, 349 | Sinfs: []Sinf{ 350 | { 351 | ID: 0, 352 | Data: []byte("test-sinf-data"), 353 | }, 354 | }, 355 | }, 356 | }, 357 | }, 358 | }, nil) 359 | 360 | mockOS.EXPECT(). 361 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 362 | Return(testFile, nil) 363 | 364 | mockHTTPClient.EXPECT(). 365 | NewRequest("GET", gomock.Any(), nil). 366 | Return(nil, nil) 367 | 368 | mockHTTPClient.EXPECT(). 369 | Do(gomock.Any()). 370 | Return(&gohttp.Response{ 371 | Body: io.NopCloser(strings.NewReader("ping")), 372 | }, nil) 373 | }) 374 | 375 | AfterEach(func() { 376 | err := os.Remove(testFile.Name()) 377 | Expect(err).ToNot(HaveOccurred()) 378 | }) 379 | 380 | It("writes data to file", func() { 381 | mockOS.EXPECT(). 382 | Getwd(). 383 | Return("", nil) 384 | 385 | _, err := as.Download(DownloadInput{}) 386 | Expect(err).To(HaveOccurred()) 387 | 388 | testData, err := os.ReadFile(testFile.Name()) 389 | Expect(err).ToNot(HaveOccurred()) 390 | Expect(string(testData)).To(Equal("ping")) 391 | }) 392 | 393 | When("successfully applies patches", func() { 394 | var ( 395 | tmpFile *os.File 396 | outputPath string 397 | ) 398 | 399 | BeforeEach(func() { 400 | var err error 401 | tmpFile, err = os.OpenFile(fmt.Sprintf("%s.tmp", testFile.Name()), os.O_CREATE|os.O_WRONLY, 0644) 402 | Expect(err).ToNot(HaveOccurred()) 403 | 404 | outputPath = strings.TrimSuffix(tmpFile.Name(), ".tmp") 405 | 406 | mockOS.EXPECT(). 407 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 408 | DoAndReturn(os.OpenFile) 409 | 410 | mockOS.EXPECT(). 411 | Stat(gomock.Any()). 412 | Return(nil, nil) 413 | 414 | mockOS.EXPECT(). 415 | Remove(tmpFile.Name()). 416 | Return(nil) 417 | 418 | zipFile := zip.NewWriter(tmpFile) 419 | w, err := zipFile.Create("Payload/Test.app/Info.plist") 420 | Expect(err).ToNot(HaveOccurred()) 421 | 422 | info, err := plist.Marshal(map[string]interface{}{ 423 | "CFBundleExecutable": "Test", 424 | }, plist.BinaryFormat) 425 | Expect(err).ToNot(HaveOccurred()) 426 | 427 | _, err = w.Write(info) 428 | Expect(err).ToNot(HaveOccurred()) 429 | 430 | err = zipFile.Close() 431 | Expect(err).ToNot(HaveOccurred()) 432 | }) 433 | 434 | AfterEach(func() { 435 | err := os.Remove(tmpFile.Name()) 436 | Expect(err).ToNot(HaveOccurred()) 437 | }) 438 | 439 | It("succeeds", func() { 440 | out, err := as.Download(DownloadInput{ 441 | OutputPath: outputPath, 442 | }) 443 | Expect(err).ToNot(HaveOccurred()) 444 | Expect(out.DestinationPath).ToNot(BeEmpty()) 445 | }) 446 | }) 447 | }) 448 | }) 449 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_login.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | gohttp "net/http" 8 | "strconv" 9 | "strings" 10 | 11 | "github.com/majd/ipatool/v2/pkg/http" 12 | "github.com/majd/ipatool/v2/pkg/util" 13 | ) 14 | 15 | var ( 16 | ErrAuthCodeRequired = errors.New("auth code is required") 17 | ) 18 | 19 | type LoginInput struct { 20 | Email string 21 | Password string 22 | AuthCode string 23 | } 24 | 25 | type LoginOutput struct { 26 | Account Account 27 | } 28 | 29 | func (t *appstore) Login(input LoginInput) (LoginOutput, error) { 30 | macAddr, err := t.machine.MacAddress() 31 | if err != nil { 32 | return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err) 33 | } 34 | 35 | guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") 36 | 37 | acc, err := t.login(input.Email, input.Password, input.AuthCode, guid) 38 | if err != nil { 39 | return LoginOutput{}, err 40 | } 41 | 42 | return LoginOutput{ 43 | Account: acc, 44 | }, nil 45 | } 46 | 47 | type loginAddressResult struct { 48 | FirstName string `plist:"firstName,omitempty"` 49 | LastName string `plist:"lastName,omitempty"` 50 | } 51 | 52 | type loginAccountResult struct { 53 | Email string `plist:"appleId,omitempty"` 54 | Address loginAddressResult `plist:"address,omitempty"` 55 | } 56 | 57 | type loginResult struct { 58 | FailureType string `plist:"failureType,omitempty"` 59 | CustomerMessage string `plist:"customerMessage,omitempty"` 60 | Account loginAccountResult `plist:"accountInfo,omitempty"` 61 | DirectoryServicesID string `plist:"dsPersonId,omitempty"` 62 | PasswordToken string `plist:"passwordToken,omitempty"` 63 | } 64 | 65 | func (t *appstore) login(email, password, authCode, guid string) (Account, error) { 66 | redirect := "" 67 | 68 | var ( 69 | err error 70 | res http.Result[loginResult] 71 | ) 72 | 73 | retry := true 74 | 75 | for attempt := 1; retry && attempt <= 4; attempt++ { 76 | request := t.loginRequest(email, password, authCode, guid, attempt) 77 | request.URL, _ = util.IfEmpty(redirect, request.URL), "" 78 | res, err = t.loginClient.Send(request) 79 | 80 | if err != nil { 81 | return Account{}, fmt.Errorf("request failed: %w", err) 82 | } 83 | 84 | if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil { 85 | return Account{}, err 86 | } 87 | } 88 | 89 | if retry { 90 | return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res) 91 | } 92 | 93 | sf, err := res.GetHeader(HTTPHeaderStoreFront) 94 | if err != nil { 95 | return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res) 96 | } 97 | 98 | addr := res.Data.Account.Address 99 | acc := Account{ 100 | Name: strings.Join([]string{addr.FirstName, addr.LastName}, " "), 101 | Email: res.Data.Account.Email, 102 | PasswordToken: res.Data.PasswordToken, 103 | DirectoryServicesID: res.Data.DirectoryServicesID, 104 | StoreFront: sf, 105 | Password: password, 106 | } 107 | 108 | data, err := json.Marshal(acc) 109 | if err != nil { 110 | return Account{}, fmt.Errorf("failed to marshal json: %w", err) 111 | } 112 | 113 | err = t.keychain.Set("account", data) 114 | if err != nil { 115 | return Account{}, fmt.Errorf("failed to save account in keychain: %w", err) 116 | } 117 | 118 | return acc, nil 119 | } 120 | 121 | func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) { 122 | var ( 123 | retry bool 124 | redirect string 125 | err error 126 | ) 127 | 128 | if res.StatusCode == gohttp.StatusFound { 129 | if redirect, err = res.GetHeader("location"); err != nil { 130 | err = fmt.Errorf("failed to retrieve redirect location: %w", err) 131 | } else { 132 | retry = true 133 | } 134 | } else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials { 135 | retry = true 136 | } else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { 137 | err = ErrAuthCodeRequired 138 | } else if res.Data.FailureType != "" { 139 | if res.Data.CustomerMessage != "" { 140 | err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) 141 | } else { 142 | err = NewErrorWithMetadata(errors.New("something went wrong"), res) 143 | } 144 | } else if res.StatusCode != gohttp.StatusOK || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" { 145 | err = NewErrorWithMetadata(errors.New("something went wrong"), res) 146 | } 147 | 148 | return retry, redirect, err 149 | } 150 | 151 | func (t *appstore) loginRequest(email, password, authCode, guid string, attempt int) http.Request { 152 | return http.Request{ 153 | Method: http.MethodPOST, 154 | URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathAuthenticate), 155 | ResponseFormat: http.ResponseFormatXML, 156 | Headers: map[string]string{ 157 | "Content-Type": "application/x-www-form-urlencoded", 158 | }, 159 | Payload: &http.XMLPayload{ 160 | Content: map[string]interface{}{ 161 | "appleId": email, 162 | "attempt": strconv.Itoa(attempt), 163 | "guid": guid, 164 | "password": fmt.Sprintf("%s%s", password, authCode), 165 | "rmp": "0", 166 | "why": "signIn", 167 | }, 168 | }, 169 | } 170 | } 171 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_login_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "encoding/json" 5 | "errors" 6 | "fmt" 7 | "strings" 8 | 9 | "github.com/majd/ipatool/v2/pkg/http" 10 | "github.com/majd/ipatool/v2/pkg/keychain" 11 | "github.com/majd/ipatool/v2/pkg/util/machine" 12 | . "github.com/onsi/ginkgo/v2" 13 | . "github.com/onsi/gomega" 14 | "go.uber.org/mock/gomock" 15 | ) 16 | 17 | var _ = Describe("AppStore (Login)", func() { 18 | const ( 19 | testPassword = "test-password" 20 | testEmail = "test-email" 21 | testFirstName = "test-first-name" 22 | testLastName = "test-last-name" 23 | ) 24 | 25 | var ( 26 | ctrl *gomock.Controller 27 | as AppStore 28 | mockKeychain *keychain.MockKeychain 29 | mockClient *http.MockClient[loginResult] 30 | mockMachine *machine.MockMachine 31 | ) 32 | 33 | BeforeEach(func() { 34 | ctrl = gomock.NewController(GinkgoT()) 35 | mockKeychain = keychain.NewMockKeychain(ctrl) 36 | mockClient = http.NewMockClient[loginResult](ctrl) 37 | mockMachine = machine.NewMockMachine(ctrl) 38 | as = &appstore{ 39 | keychain: mockKeychain, 40 | loginClient: mockClient, 41 | machine: mockMachine, 42 | } 43 | }) 44 | 45 | AfterEach(func() { 46 | ctrl.Finish() 47 | }) 48 | 49 | When("fails to read Machine's MAC address", func() { 50 | BeforeEach(func() { 51 | mockMachine.EXPECT(). 52 | MacAddress(). 53 | Return("", errors.New("")) 54 | }) 55 | 56 | It("returns error", func() { 57 | _, err := as.Login(LoginInput{ 58 | Password: testPassword, 59 | }) 60 | Expect(err).To(HaveOccurred()) 61 | }) 62 | }) 63 | 64 | When("successfully reads machine's MAC address", func() { 65 | BeforeEach(func() { 66 | mockMachine.EXPECT(). 67 | MacAddress(). 68 | Return("00:00:00:00:00:00", nil) 69 | }) 70 | 71 | When("client returns error", func() { 72 | BeforeEach(func() { 73 | mockClient.EXPECT(). 74 | Send(gomock.Any()). 75 | Return(http.Result[loginResult]{}, errors.New("")) 76 | }) 77 | 78 | It("returns wrapped error", func() { 79 | _, err := as.Login(LoginInput{ 80 | Password: testPassword, 81 | }) 82 | Expect(err).To(HaveOccurred()) 83 | }) 84 | }) 85 | 86 | When("store API returns invalid first response", func() { 87 | BeforeEach(func() { 88 | mockClient.EXPECT(). 89 | Send(gomock.Any()). 90 | Return(http.Result[loginResult]{ 91 | Data: loginResult{ 92 | FailureType: FailureTypeInvalidCredentials, 93 | CustomerMessage: "test", 94 | }, 95 | }, nil). 96 | Times(2) 97 | }) 98 | 99 | It("retries one more time", func() { 100 | _, err := as.Login(LoginInput{ 101 | Password: testPassword, 102 | }) 103 | Expect(err).To(HaveOccurred()) 104 | }) 105 | }) 106 | 107 | When("store API returns error", func() { 108 | BeforeEach(func() { 109 | mockClient.EXPECT(). 110 | Send(gomock.Any()). 111 | Return(http.Result[loginResult]{ 112 | Data: loginResult{ 113 | FailureType: "random-error", 114 | }, 115 | }, nil) 116 | }) 117 | 118 | It("returns error", func() { 119 | _, err := as.Login(LoginInput{ 120 | Password: testPassword, 121 | }) 122 | Expect(err).To(HaveOccurred()) 123 | }) 124 | }) 125 | 126 | When("store API requires 2FA code", func() { 127 | BeforeEach(func() { 128 | mockClient.EXPECT(). 129 | Send(gomock.Any()). 130 | Return(http.Result[loginResult]{ 131 | Data: loginResult{ 132 | FailureType: "", 133 | CustomerMessage: CustomerMessageBadLogin, 134 | }, 135 | }, nil) 136 | }) 137 | 138 | It("returns ErrAuthCodeRequired error", func() { 139 | _, err := as.Login(LoginInput{ 140 | Password: testPassword, 141 | }) 142 | Expect(err).To(Equal(ErrAuthCodeRequired)) 143 | }) 144 | }) 145 | 146 | When("store API redirects", func() { 147 | const ( 148 | testRedirectLocation = "https://" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathAuthenticate + "?PRH=31&Pod=31" 149 | ) 150 | 151 | BeforeEach(func() { 152 | firstCall := mockClient.EXPECT(). 153 | Send(gomock.Any()). 154 | Do(func(req http.Request) { 155 | Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) 156 | x := req.Payload.(*http.XMLPayload) 157 | Expect(x.Content).To(HaveKeyWithValue("attempt", "1")) 158 | }). 159 | Return(http.Result[loginResult]{ 160 | StatusCode: 302, 161 | Headers: map[string]string{"Location": testRedirectLocation}, 162 | }, nil) 163 | secondCall := mockClient.EXPECT(). 164 | Send(gomock.Any()). 165 | Do(func(req http.Request) { 166 | Expect(req.URL).To(Equal(testRedirectLocation)) 167 | Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) 168 | x := req.Payload.(*http.XMLPayload) 169 | Expect(x.Content).To(HaveKeyWithValue("attempt", "2")) 170 | }). 171 | Return(http.Result[loginResult]{}, errors.New("test complete")) 172 | gomock.InOrder(firstCall, secondCall) 173 | }) 174 | 175 | It("follows the redirect and increments attempt", func() { 176 | _, err := as.Login(LoginInput{ 177 | Password: testPassword, 178 | }) 179 | Expect(err).To(MatchError("request failed: test complete")) 180 | }) 181 | }) 182 | 183 | When("store API redirects too much", func() { 184 | BeforeEach(func() { 185 | mockClient.EXPECT(). 186 | Send(gomock.Any()). 187 | Return(http.Result[loginResult]{ 188 | StatusCode: 302, 189 | Headers: map[string]string{"Location": "hello"}, 190 | }, nil). 191 | Times(4) 192 | }) 193 | It("bails out", func() { 194 | _, err := as.Login(LoginInput{ 195 | Password: testPassword, 196 | }) 197 | Expect(err).To(MatchError("too many attempts")) 198 | }) 199 | }) 200 | 201 | When("store API returns valid response", func() { 202 | const ( 203 | testPasswordToken = "test-password-token" 204 | testDirectoryServicesID = "directory-services-id" 205 | testStoreFront = "test-storefront" 206 | ) 207 | 208 | BeforeEach(func() { 209 | mockClient.EXPECT(). 210 | Send(gomock.Any()). 211 | Return(http.Result[loginResult]{ 212 | StatusCode: 200, 213 | Headers: map[string]string{HTTPHeaderStoreFront: testStoreFront}, 214 | Data: loginResult{ 215 | PasswordToken: testPasswordToken, 216 | DirectoryServicesID: testDirectoryServicesID, 217 | Account: loginAccountResult{ 218 | Email: testEmail, 219 | Address: loginAddressResult{ 220 | FirstName: testFirstName, 221 | LastName: testLastName, 222 | }, 223 | }, 224 | }, 225 | }, nil) 226 | }) 227 | 228 | When("fails to save account in keychain", func() { 229 | BeforeEach(func() { 230 | mockKeychain.EXPECT(). 231 | Set("account", gomock.Any()). 232 | Do(func(key string, data []byte) { 233 | want := Account{ 234 | Name: fmt.Sprintf("%s %s", testFirstName, testLastName), 235 | Email: testEmail, 236 | PasswordToken: testPasswordToken, 237 | Password: testPassword, 238 | DirectoryServicesID: testDirectoryServicesID, 239 | StoreFront: testStoreFront, 240 | } 241 | 242 | var got Account 243 | err := json.Unmarshal(data, &got) 244 | Expect(err).ToNot(HaveOccurred()) 245 | Expect(got).To(Equal(want)) 246 | }). 247 | Return(errors.New("")) 248 | }) 249 | 250 | It("returns error", func() { 251 | _, err := as.Login(LoginInput{ 252 | Password: testPassword, 253 | }) 254 | Expect(err).To(HaveOccurred()) 255 | }) 256 | }) 257 | 258 | When("successfully saves account in keychain", func() { 259 | BeforeEach(func() { 260 | mockKeychain.EXPECT(). 261 | Set("account", gomock.Any()). 262 | Do(func(key string, data []byte) { 263 | want := Account{ 264 | Name: fmt.Sprintf("%s %s", testFirstName, testLastName), 265 | Email: testEmail, 266 | PasswordToken: testPasswordToken, 267 | Password: testPassword, 268 | DirectoryServicesID: testDirectoryServicesID, 269 | StoreFront: testStoreFront, 270 | } 271 | 272 | var got Account 273 | err := json.Unmarshal(data, &got) 274 | Expect(err).ToNot(HaveOccurred()) 275 | Expect(got).To(Equal(want)) 276 | }). 277 | Return(nil) 278 | }) 279 | 280 | It("returns nil", func() { 281 | out, err := as.Login(LoginInput{ 282 | Password: testPassword, 283 | }) 284 | Expect(err).ToNot(HaveOccurred()) 285 | Expect(out.Account.Email).To(Equal(testEmail)) 286 | Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) 287 | }) 288 | }) 289 | }) 290 | }) 291 | }) 292 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_lookup.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | gohttp "net/http" 7 | "net/url" 8 | 9 | "github.com/majd/ipatool/v2/pkg/http" 10 | ) 11 | 12 | type LookupInput struct { 13 | Account Account 14 | BundleID string 15 | } 16 | 17 | type LookupOutput struct { 18 | App App 19 | } 20 | 21 | func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { 22 | countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) 23 | if err != nil { 24 | return LookupOutput{}, fmt.Errorf("failed to reoslve the country code: %w", err) 25 | } 26 | 27 | request := t.lookupRequest(input.BundleID, countryCode) 28 | 29 | res, err := t.searchClient.Send(request) 30 | if err != nil { 31 | return LookupOutput{}, fmt.Errorf("request failed: %w", err) 32 | } 33 | 34 | if res.StatusCode != gohttp.StatusOK { 35 | return LookupOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) 36 | } 37 | 38 | if len(res.Data.Results) == 0 { 39 | return LookupOutput{}, errors.New("app not found") 40 | } 41 | 42 | return LookupOutput{ 43 | App: res.Data.Results[0], 44 | }, nil 45 | } 46 | 47 | func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request { 48 | return http.Request{ 49 | URL: t.lookupURL(bundleID, countryCode), 50 | Method: http.MethodGET, 51 | ResponseFormat: http.ResponseFormatJSON, 52 | } 53 | } 54 | 55 | func (t *appstore) lookupURL(bundleID, countryCode string) string { 56 | params := url.Values{} 57 | params.Add("entity", "software,iPadSoftware") 58 | params.Add("limit", "1") 59 | params.Add("media", "software") 60 | params.Add("bundleId", bundleID) 61 | params.Add("country", countryCode) 62 | 63 | return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode()) 64 | } 65 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_lookup_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/majd/ipatool/v2/pkg/http" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | var _ = Describe("AppStore (Lookup)", func() { 13 | var ( 14 | ctrl *gomock.Controller 15 | mockClient *http.MockClient[searchResult] 16 | as AppStore 17 | ) 18 | 19 | BeforeEach(func() { 20 | ctrl = gomock.NewController(GinkgoT()) 21 | mockClient = http.NewMockClient[searchResult](ctrl) 22 | as = &appstore{ 23 | searchClient: mockClient, 24 | } 25 | }) 26 | 27 | AfterEach(func() { 28 | ctrl.Finish() 29 | }) 30 | 31 | When("request is successful", func() { 32 | When("does not find app", func() { 33 | BeforeEach(func() { 34 | mockClient.EXPECT(). 35 | Send(gomock.Any()). 36 | Return(http.Result[searchResult]{ 37 | StatusCode: 200, 38 | Data: searchResult{ 39 | Count: 0, 40 | Results: []App{}, 41 | }, 42 | }, nil) 43 | }) 44 | 45 | It("returns error", func() { 46 | _, err := as.Lookup(LookupInput{ 47 | Account: Account{ 48 | StoreFront: "143441", 49 | }, 50 | }) 51 | Expect(err).To(HaveOccurred()) 52 | }) 53 | }) 54 | 55 | When("finds app", func() { 56 | var testApp = App{ 57 | ID: 1, 58 | BundleID: "app.bundle.id", 59 | Name: "app name", 60 | Version: "1.0", 61 | Price: 0.99, 62 | } 63 | 64 | BeforeEach(func() { 65 | mockClient.EXPECT(). 66 | Send(gomock.Any()). 67 | Return(http.Result[searchResult]{ 68 | StatusCode: 200, 69 | Data: searchResult{ 70 | Count: 1, 71 | Results: []App{testApp}, 72 | }, 73 | }, nil) 74 | }) 75 | 76 | It("returns app", func() { 77 | app, err := as.Lookup(LookupInput{ 78 | Account: Account{ 79 | StoreFront: "143441", 80 | }, 81 | }) 82 | Expect(err).ToNot(HaveOccurred()) 83 | Expect(app).To(Equal(LookupOutput{App: testApp})) 84 | }) 85 | }) 86 | }) 87 | 88 | When("store front is invalid", func() { 89 | It("returns error", func() { 90 | _, err := as.Lookup(LookupInput{ 91 | Account: Account{ 92 | StoreFront: "xyz", 93 | }, 94 | }) 95 | Expect(err).To(HaveOccurred()) 96 | }) 97 | }) 98 | 99 | When("request fails", func() { 100 | BeforeEach(func() { 101 | mockClient.EXPECT(). 102 | Send(gomock.Any()). 103 | Return(http.Result[searchResult]{}, errors.New("")) 104 | }) 105 | 106 | It("returns error", func() { 107 | _, err := as.Lookup(LookupInput{ 108 | Account: Account{ 109 | StoreFront: "143441", 110 | }, 111 | }) 112 | Expect(err).To(HaveOccurred()) 113 | }) 114 | }) 115 | 116 | When("request returns bad status code", func() { 117 | BeforeEach(func() { 118 | mockClient.EXPECT(). 119 | Send(gomock.Any()). 120 | Return(http.Result[searchResult]{ 121 | StatusCode: 400, 122 | }, nil) 123 | }) 124 | 125 | It("returns error", func() { 126 | _, err := as.Lookup(LookupInput{ 127 | Account: Account{ 128 | StoreFront: "143441", 129 | }, 130 | }) 131 | Expect(err).To(HaveOccurred()) 132 | }) 133 | }) 134 | }) 135 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_purchase.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | gohttp "net/http" 7 | "strings" 8 | 9 | "github.com/majd/ipatool/v2/pkg/http" 10 | ) 11 | 12 | var ( 13 | ErrPasswordTokenExpired = errors.New("password token is expired") 14 | ErrSubscriptionRequired = errors.New("subscription required") 15 | ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable") 16 | ) 17 | 18 | type PurchaseInput struct { 19 | Account Account 20 | App App 21 | } 22 | 23 | func (t *appstore) Purchase(input PurchaseInput) error { 24 | macAddr, err := t.machine.MacAddress() 25 | if err != nil { 26 | return fmt.Errorf("failed to get mac address: %w", err) 27 | } 28 | 29 | guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") 30 | 31 | if input.App.Price > 0 { 32 | return errors.New("purchasing paid apps is not supported") 33 | } 34 | 35 | err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppStore) 36 | if err != nil { 37 | if err == ErrTemporarilyUnavailable { 38 | err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppleArcade) 39 | if err != nil { 40 | return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppleArcade, err) 41 | } 42 | 43 | return nil 44 | } 45 | 46 | return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppStore, err) 47 | } 48 | 49 | return nil 50 | } 51 | 52 | type purchaseResult struct { 53 | FailureType string `plist:"failureType,omitempty"` 54 | CustomerMessage string `plist:"customerMessage,omitempty"` 55 | JingleDocType string `plist:"jingleDocType,omitempty"` 56 | Status int `plist:"status,omitempty"` 57 | } 58 | 59 | func (t *appstore) purchaseWithParams(acc Account, app App, guid string, pricingParameters string) error { 60 | req := t.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters) 61 | res, err := t.purchaseClient.Send(req) 62 | 63 | if err != nil { 64 | return fmt.Errorf("request failed: %w", err) 65 | } 66 | 67 | if res.Data.FailureType == FailureTypeTemporarilyUnavailable { 68 | return ErrTemporarilyUnavailable 69 | } 70 | 71 | if res.Data.CustomerMessage == CustomerMessageSubscriptionRequired { 72 | return ErrSubscriptionRequired 73 | } 74 | 75 | if res.Data.FailureType == FailureTypePasswordTokenExpired { 76 | return ErrPasswordTokenExpired 77 | } 78 | 79 | if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { 80 | return NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) 81 | } 82 | 83 | if res.Data.FailureType != "" { 84 | return NewErrorWithMetadata(errors.New("something went wrong"), res) 85 | } 86 | 87 | if res.StatusCode == gohttp.StatusInternalServerError { 88 | return fmt.Errorf("license already exists") 89 | } 90 | 91 | if res.Data.JingleDocType != "purchaseSuccess" || res.Data.Status != 0 { 92 | return NewErrorWithMetadata(errors.New("failed to purchase app"), res) 93 | } 94 | 95 | return nil 96 | } 97 | 98 | func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request { 99 | return http.Request{ 100 | URL: fmt.Sprintf("https://%s%s", PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase), 101 | Method: http.MethodPOST, 102 | ResponseFormat: http.ResponseFormatXML, 103 | Headers: map[string]string{ 104 | "Content-Type": "application/x-apple-plist", 105 | "iCloud-DSID": acc.DirectoryServicesID, 106 | "X-Dsid": acc.DirectoryServicesID, 107 | "X-Apple-Store-Front": storeFront, 108 | "X-Token": acc.PasswordToken, 109 | }, 110 | Payload: &http.XMLPayload{ 111 | Content: map[string]interface{}{ 112 | "appExtVrsId": "0", 113 | "hasAskedToFulfillPreorder": "true", 114 | "buyWithoutAuthorization": "true", 115 | "hasDoneAgeCheck": "true", 116 | "guid": guid, 117 | "needDiv": "0", 118 | "origPage": fmt.Sprintf("Software-%d", app.ID), 119 | "origPageLocation": "Buy", 120 | "price": "0", 121 | "pricingParameters": pricingParameters, 122 | "productType": "C", 123 | "salableAdamId": app.ID, 124 | }, 125 | }, 126 | } 127 | } 128 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_purchase_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/majd/ipatool/v2/pkg/http" 7 | "github.com/majd/ipatool/v2/pkg/keychain" 8 | "github.com/majd/ipatool/v2/pkg/util/machine" 9 | . "github.com/onsi/ginkgo/v2" 10 | . "github.com/onsi/gomega" 11 | "go.uber.org/mock/gomock" 12 | ) 13 | 14 | var _ = Describe("AppStore (Purchase)", func() { 15 | var ( 16 | ctrl *gomock.Controller 17 | mockKeychain *keychain.MockKeychain 18 | mockMachine *machine.MockMachine 19 | mockPurchaseClient *http.MockClient[purchaseResult] 20 | mockLoginClient *http.MockClient[loginResult] 21 | as *appstore 22 | ) 23 | 24 | BeforeEach(func() { 25 | ctrl = gomock.NewController(GinkgoT()) 26 | mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) 27 | mockLoginClient = http.NewMockClient[loginResult](ctrl) 28 | mockKeychain = keychain.NewMockKeychain(ctrl) 29 | mockMachine = machine.NewMockMachine(ctrl) 30 | as = &appstore{ 31 | keychain: mockKeychain, 32 | purchaseClient: mockPurchaseClient, 33 | loginClient: mockLoginClient, 34 | machine: mockMachine, 35 | } 36 | }) 37 | 38 | AfterEach(func() { 39 | ctrl.Finish() 40 | }) 41 | 42 | When("fails to read MAC address", func() { 43 | BeforeEach(func() { 44 | mockMachine.EXPECT(). 45 | MacAddress(). 46 | Return("", errors.New("")) 47 | }) 48 | 49 | It("returns error", func() { 50 | err := as.Purchase(PurchaseInput{}) 51 | Expect(err).To(HaveOccurred()) 52 | }) 53 | }) 54 | 55 | When("app is paid", func() { 56 | BeforeEach(func() { 57 | mockMachine.EXPECT(). 58 | MacAddress(). 59 | Return("00:00:00:00:00:00", nil) 60 | }) 61 | 62 | It("returns error", func() { 63 | err := as.Purchase(PurchaseInput{ 64 | Account: Account{ 65 | StoreFront: "143441", 66 | }, 67 | App: App{ 68 | Price: 0.99, 69 | }, 70 | }) 71 | Expect(err).To(HaveOccurred()) 72 | }) 73 | }) 74 | 75 | When("purchase request fails", func() { 76 | BeforeEach(func() { 77 | mockMachine.EXPECT(). 78 | MacAddress(). 79 | Return("00:00:00:00:00:00", nil) 80 | 81 | mockPurchaseClient.EXPECT(). 82 | Send(gomock.Any()). 83 | Return(http.Result[purchaseResult]{}, errors.New("")) 84 | }) 85 | 86 | It("returns error", func() { 87 | err := as.Purchase(PurchaseInput{ 88 | Account: Account{ 89 | StoreFront: "143441", 90 | }, 91 | }) 92 | Expect(err).To(HaveOccurred()) 93 | }) 94 | }) 95 | 96 | When("password token is expired", func() { 97 | BeforeEach(func() { 98 | mockMachine.EXPECT(). 99 | MacAddress(). 100 | Return("00:00:00:00:00:00", nil) 101 | 102 | mockPurchaseClient.EXPECT(). 103 | Send(gomock.Any()). 104 | Return(http.Result[purchaseResult]{ 105 | Data: purchaseResult{ 106 | FailureType: FailureTypePasswordTokenExpired, 107 | }, 108 | }, nil) 109 | }) 110 | 111 | It("returns error", func() { 112 | err := as.Purchase(PurchaseInput{ 113 | Account: Account{ 114 | StoreFront: "143441", 115 | }, 116 | }) 117 | Expect(err).To(HaveOccurred()) 118 | }) 119 | }) 120 | 121 | When("store API returns customer error message", func() { 122 | BeforeEach(func() { 123 | mockMachine.EXPECT(). 124 | MacAddress(). 125 | Return("00:00:00:00:00:00", nil) 126 | 127 | mockPurchaseClient.EXPECT(). 128 | Send(gomock.Any()). 129 | Return(http.Result[purchaseResult]{ 130 | Data: purchaseResult{ 131 | FailureType: "failure", 132 | CustomerMessage: CustomerMessageBadLogin, 133 | }, 134 | }, nil) 135 | }) 136 | 137 | It("returns error", func() { 138 | err := as.Purchase(PurchaseInput{ 139 | Account: Account{ 140 | StoreFront: "143441", 141 | }, 142 | }) 143 | Expect(err).To(HaveOccurred()) 144 | }) 145 | }) 146 | 147 | When("store API returns unknown error", func() { 148 | BeforeEach(func() { 149 | mockMachine.EXPECT(). 150 | MacAddress(). 151 | Return("00:00:00:00:00:00", nil) 152 | 153 | mockPurchaseClient.EXPECT(). 154 | Send(gomock.Any()). 155 | Return(http.Result[purchaseResult]{ 156 | Data: purchaseResult{ 157 | FailureType: "failure", 158 | }, 159 | }, nil) 160 | }) 161 | 162 | It("returns error", func() { 163 | err := as.Purchase(PurchaseInput{ 164 | Account: Account{ 165 | StoreFront: "143441", 166 | }, 167 | }) 168 | Expect(err).To(HaveOccurred()) 169 | }) 170 | }) 171 | 172 | When("account already has a license for the app", func() { 173 | BeforeEach(func() { 174 | mockMachine.EXPECT(). 175 | MacAddress(). 176 | Return("00:00:00:00:00:00", nil) 177 | 178 | mockPurchaseClient.EXPECT(). 179 | Send(gomock.Any()). 180 | Return(http.Result[purchaseResult]{ 181 | StatusCode: 500, 182 | Data: purchaseResult{}, 183 | }, nil) 184 | }) 185 | 186 | It("returns error", func() { 187 | err := as.Purchase(PurchaseInput{ 188 | Account: Account{ 189 | StoreFront: "143441", 190 | }, 191 | }) 192 | Expect(err).To(HaveOccurred()) 193 | }) 194 | }) 195 | 196 | When("subscription is required", func() { 197 | BeforeEach(func() { 198 | mockMachine.EXPECT(). 199 | MacAddress(). 200 | Return("00:00:00:00:00:00", nil) 201 | 202 | mockPurchaseClient.EXPECT(). 203 | Send(pricingParametersMatcher{"STDQ"}). 204 | Return(http.Result[purchaseResult]{ 205 | StatusCode: 200, 206 | Data: purchaseResult{ 207 | CustomerMessage: "This item is temporarily unavailable.", 208 | FailureType: FailureTypeTemporarilyUnavailable, 209 | }, 210 | }, nil) 211 | 212 | mockPurchaseClient.EXPECT(). 213 | Send(pricingParametersMatcher{"GAME"}). 214 | Return(http.Result[purchaseResult]{ 215 | StatusCode: 200, 216 | Data: purchaseResult{ 217 | CustomerMessage: CustomerMessageSubscriptionRequired, 218 | }, 219 | }, nil) 220 | }) 221 | 222 | It("returns error", func() { 223 | err := as.Purchase(PurchaseInput{ 224 | Account: Account{ 225 | StoreFront: "143441", 226 | }, 227 | }) 228 | Expect(err).To(HaveOccurred()) 229 | }) 230 | }) 231 | 232 | When("successfully purchases the app", func() { 233 | BeforeEach(func() { 234 | mockMachine.EXPECT(). 235 | MacAddress(). 236 | Return("00:00:00:00:00:00", nil) 237 | 238 | mockPurchaseClient.EXPECT(). 239 | Send(pricingParametersMatcher{"STDQ"}). 240 | Return(http.Result[purchaseResult]{ 241 | StatusCode: 200, 242 | Data: purchaseResult{ 243 | CustomerMessage: "This item is temporarily unavailable.", 244 | FailureType: FailureTypeTemporarilyUnavailable, 245 | }, 246 | }, nil) 247 | 248 | mockPurchaseClient.EXPECT(). 249 | Send(pricingParametersMatcher{"GAME"}). 250 | Return(http.Result[purchaseResult]{ 251 | StatusCode: 200, 252 | Data: purchaseResult{ 253 | JingleDocType: "purchaseSuccess", 254 | Status: 0, 255 | }, 256 | }, nil) 257 | }) 258 | 259 | It("returns nil", func() { 260 | err := as.Purchase(PurchaseInput{ 261 | Account: Account{ 262 | StoreFront: "143441", 263 | }, 264 | }) 265 | Expect(err).ToNot(HaveOccurred()) 266 | }) 267 | }) 268 | 269 | When("purchasing the app fails", func() { 270 | BeforeEach(func() { 271 | mockMachine.EXPECT(). 272 | MacAddress(). 273 | Return("00:00:00:00:00:00", nil) 274 | 275 | mockPurchaseClient.EXPECT(). 276 | Send(gomock.Any()). 277 | Return(http.Result[purchaseResult]{ 278 | StatusCode: 200, 279 | Data: purchaseResult{ 280 | JingleDocType: "failure", 281 | Status: -1, 282 | }, 283 | }, nil) 284 | }) 285 | 286 | It("returns nil", func() { 287 | err := as.Purchase(PurchaseInput{ 288 | Account: Account{ 289 | StoreFront: "143441", 290 | }, 291 | }) 292 | Expect(err).To(HaveOccurred()) 293 | }) 294 | }) 295 | }) 296 | 297 | type pricingParametersMatcher struct { 298 | pricingParameters string 299 | } 300 | 301 | func (p pricingParametersMatcher) Matches(in interface{}) bool { 302 | return in.(http.Request).Payload.(*http.XMLPayload).Content["pricingParameters"] == p.pricingParameters 303 | } 304 | 305 | func (p pricingParametersMatcher) String() string { 306 | return "payload pricingParameters is " + p.pricingParameters 307 | } 308 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_replicate_sinf.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "archive/zip" 5 | "bytes" 6 | "errors" 7 | "fmt" 8 | "io" 9 | "os" 10 | "path/filepath" 11 | "strings" 12 | 13 | "github.com/majd/ipatool/v2/pkg/util" 14 | "howett.net/plist" 15 | ) 16 | 17 | type Sinf struct { 18 | ID int64 `plist:"id,omitempty"` 19 | Data []byte `plist:"sinf,omitempty"` 20 | } 21 | 22 | type ReplicateSinfInput struct { 23 | Sinfs []Sinf 24 | PackagePath string 25 | } 26 | 27 | func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error { 28 | zipReader, err := zip.OpenReader(input.PackagePath) 29 | if err != nil { 30 | return errors.New("failed to open zip reader") 31 | } 32 | 33 | tmpPath := fmt.Sprintf("%s.tmp", input.PackagePath) 34 | tmpFile, err := t.os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644) 35 | 36 | if err != nil { 37 | return fmt.Errorf("failed to open file: %w", err) 38 | } 39 | 40 | zipWriter := zip.NewWriter(tmpFile) 41 | 42 | err = t.replicateZip(zipReader, zipWriter) 43 | if err != nil { 44 | return fmt.Errorf("failed to replicate zip: %w", err) 45 | } 46 | 47 | bundleName, err := t.readBundleName(zipReader) 48 | if err != nil { 49 | return fmt.Errorf("failed to read bundle name: %w", err) 50 | } 51 | 52 | manifest, err := t.readManifestPlist(zipReader) 53 | if err != nil { 54 | return fmt.Errorf("failed to read manifest plist: %w", err) 55 | } 56 | 57 | info, err := t.readInfoPlist(zipReader) 58 | if err != nil { 59 | return fmt.Errorf("failed to read info plist: %w", err) 60 | } 61 | 62 | if manifest != nil { 63 | err = t.replicateSinfFromManifest(*manifest, zipWriter, input.Sinfs, bundleName) 64 | } else { 65 | err = t.replicateSinfFromInfo(*info, zipWriter, input.Sinfs, bundleName) 66 | } 67 | 68 | if err != nil { 69 | return fmt.Errorf("failed to replicate sinf: %w", err) 70 | } 71 | 72 | zipReader.Close() 73 | zipWriter.Close() 74 | tmpFile.Close() 75 | 76 | err = t.os.Remove(input.PackagePath) 77 | if err != nil { 78 | return fmt.Errorf("failed to remove original file: %w", err) 79 | } 80 | 81 | err = t.os.Rename(tmpPath, input.PackagePath) 82 | if err != nil { 83 | return fmt.Errorf("failed to remove original file: %w", err) 84 | } 85 | 86 | return nil 87 | } 88 | 89 | type packageManifest struct { 90 | SinfPaths []string `plist:"SinfPaths,omitempty"` 91 | } 92 | 93 | type packageInfo struct { 94 | BundleExecutable string `plist:"CFBundleExecutable,omitempty"` 95 | } 96 | 97 | func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip *zip.Writer, sinfs []Sinf, bundleName string) error { 98 | zipped, err := util.Zip(sinfs, manifest.SinfPaths) 99 | if err != nil { 100 | return fmt.Errorf("failed to zip sinfs: %w", err) 101 | } 102 | 103 | for _, pair := range zipped { 104 | sp := fmt.Sprintf("Payload/%s.app/%s", bundleName, pair.Second) 105 | 106 | file, err := zip.Create(sp) 107 | if err != nil { 108 | return fmt.Errorf("failed to create file: %w", err) 109 | } 110 | 111 | _, err = file.Write(pair.First.Data) 112 | if err != nil { 113 | return fmt.Errorf("failed to write data: %w", err) 114 | } 115 | } 116 | 117 | return nil 118 | } 119 | 120 | func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinfs []Sinf, bundleName string) error { 121 | sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", bundleName, info.BundleExecutable) 122 | 123 | file, err := zip.Create(sp) 124 | if err != nil { 125 | return fmt.Errorf("failed to create file: %w", err) 126 | } 127 | 128 | _, err = file.Write(sinfs[0].Data) 129 | if err != nil { 130 | return fmt.Errorf("failed to write data: %w", err) 131 | } 132 | 133 | return nil 134 | } 135 | 136 | func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error { 137 | for _, file := range src.File { 138 | srcFile, err := file.OpenRaw() 139 | if err != nil { 140 | return fmt.Errorf("failed to open raw file: %w", err) 141 | } 142 | 143 | header := file.FileHeader 144 | dstFile, err := dst.CreateRaw(&header) 145 | 146 | if err != nil { 147 | return fmt.Errorf("failed to create raw file: %w", err) 148 | } 149 | 150 | _, err = io.Copy(dstFile, srcFile) 151 | if err != nil { 152 | return fmt.Errorf("failed to copy file: %w", err) 153 | } 154 | } 155 | 156 | return nil 157 | } 158 | 159 | func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, error) { 160 | for _, file := range reader.File { 161 | if strings.Contains(file.Name, ".app/Info.plist") { 162 | src, err := file.Open() 163 | if err != nil { 164 | return nil, fmt.Errorf("failed to open file: %w", err) 165 | } 166 | 167 | data := new(bytes.Buffer) 168 | _, err = io.Copy(data, src) 169 | 170 | if err != nil { 171 | return nil, fmt.Errorf("failed to copy data: %w", err) 172 | } 173 | 174 | var info packageInfo 175 | _, err = plist.Unmarshal(data.Bytes(), &info) 176 | 177 | if err != nil { 178 | return nil, fmt.Errorf("failed to unmarshal data: %w", err) 179 | } 180 | 181 | return &info, nil 182 | } 183 | } 184 | 185 | return nil, nil 186 | } 187 | 188 | func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageManifest, error) { 189 | for _, file := range reader.File { 190 | if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") { 191 | src, err := file.Open() 192 | if err != nil { 193 | return nil, fmt.Errorf("failed to open file: %w", err) 194 | } 195 | 196 | data := new(bytes.Buffer) 197 | _, err = io.Copy(data, src) 198 | 199 | if err != nil { 200 | return nil, fmt.Errorf("failed to copy data: %w", err) 201 | } 202 | 203 | var manifest packageManifest 204 | 205 | _, err = plist.Unmarshal(data.Bytes(), &manifest) 206 | if err != nil { 207 | return nil, fmt.Errorf("failed to unmarshal data: %w", err) 208 | } 209 | 210 | return &manifest, nil 211 | } 212 | } 213 | 214 | return nil, nil 215 | } 216 | 217 | func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) { 218 | var bundleName string 219 | 220 | for _, file := range reader.File { 221 | if strings.Contains(file.Name, ".app/Info.plist") && !strings.Contains(file.Name, "/Watch/") { 222 | bundleName = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist")) 223 | 224 | break 225 | } 226 | } 227 | 228 | if bundleName == "" { 229 | return "", errors.New("could not read bundle name") 230 | } 231 | 232 | return bundleName, nil 233 | } 234 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_replicate_sinf_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "archive/zip" 5 | "errors" 6 | "fmt" 7 | "os" 8 | 9 | "github.com/majd/ipatool/v2/pkg/http" 10 | "github.com/majd/ipatool/v2/pkg/keychain" 11 | "github.com/majd/ipatool/v2/pkg/util/machine" 12 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 13 | . "github.com/onsi/ginkgo/v2" 14 | . "github.com/onsi/gomega" 15 | "go.uber.org/mock/gomock" 16 | "howett.net/plist" 17 | ) 18 | 19 | var _ = Describe("AppStore (ReplicateSinf)", func() { 20 | var ( 21 | ctrl *gomock.Controller 22 | mockKeychain *keychain.MockKeychain 23 | mockDownloadClient *http.MockClient[downloadResult] 24 | mockPurchaseClient *http.MockClient[purchaseResult] 25 | mockLoginClient *http.MockClient[loginResult] 26 | mockHTTPClient *http.MockClient[interface{}] 27 | mockOS *operatingsystem.MockOperatingSystem 28 | mockMachine *machine.MockMachine 29 | as AppStore 30 | testFile *os.File 31 | testZip *zip.Writer 32 | ) 33 | 34 | BeforeEach(func() { 35 | ctrl = gomock.NewController(GinkgoT()) 36 | mockKeychain = keychain.NewMockKeychain(ctrl) 37 | mockDownloadClient = http.NewMockClient[downloadResult](ctrl) 38 | mockLoginClient = http.NewMockClient[loginResult](ctrl) 39 | mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) 40 | mockHTTPClient = http.NewMockClient[interface{}](ctrl) 41 | mockOS = operatingsystem.NewMockOperatingSystem(ctrl) 42 | mockMachine = machine.NewMockMachine(ctrl) 43 | as = &appstore{ 44 | keychain: mockKeychain, 45 | loginClient: mockLoginClient, 46 | purchaseClient: mockPurchaseClient, 47 | downloadClient: mockDownloadClient, 48 | httpClient: mockHTTPClient, 49 | machine: mockMachine, 50 | os: mockOS, 51 | } 52 | 53 | var err error 54 | testFile, err = os.CreateTemp("", "test_file") 55 | Expect(err).ToNot(HaveOccurred()) 56 | 57 | testZip = zip.NewWriter(testFile) 58 | }) 59 | 60 | JustBeforeEach(func() { 61 | testZip.Close() 62 | }) 63 | 64 | AfterEach(func() { 65 | err := os.Remove(testFile.Name()) 66 | Expect(err).ToNot(HaveOccurred()) 67 | 68 | ctrl.Finish() 69 | }) 70 | 71 | When("app includes codesign manifest", func() { 72 | BeforeEach(func() { 73 | mockOS.EXPECT(). 74 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 75 | DoAndReturn(os.OpenFile) 76 | 77 | mockOS.EXPECT(). 78 | Remove(testFile.Name()). 79 | Return(nil) 80 | 81 | mockOS.EXPECT(). 82 | Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). 83 | Return(nil) 84 | 85 | manifest, err := plist.Marshal(packageManifest{ 86 | SinfPaths: []string{ 87 | "SC_Info/TestApp.sinf", 88 | }, 89 | }, plist.BinaryFormat) 90 | Expect(err).ToNot(HaveOccurred()) 91 | 92 | w, err := testZip.Create("Payload/Test.app/SC_Info/Manifest.plist") 93 | Expect(err).ToNot(HaveOccurred()) 94 | 95 | _, err = w.Write(manifest) 96 | Expect(err).ToNot(HaveOccurred()) 97 | 98 | w, err = testZip.Create("Payload/Test.app/Info.plist") 99 | Expect(err).ToNot(HaveOccurred()) 100 | 101 | info, err := plist.Marshal(map[string]interface{}{ 102 | "CFBundleExecutable": "Test", 103 | }, plist.BinaryFormat) 104 | Expect(err).ToNot(HaveOccurred()) 105 | 106 | _, err = w.Write(info) 107 | Expect(err).ToNot(HaveOccurred()) 108 | 109 | w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") 110 | Expect(err).ToNot(HaveOccurred()) 111 | 112 | watchInfo, err := plist.Marshal(map[string]interface{}{ 113 | "WKWatchKitApp": true, 114 | }, plist.BinaryFormat) 115 | Expect(err).ToNot(HaveOccurred()) 116 | 117 | _, err = w.Write(watchInfo) 118 | Expect(err).ToNot(HaveOccurred()) 119 | }) 120 | 121 | It("replicates sinf from manifest plist", func() { 122 | err := as.ReplicateSinf(ReplicateSinfInput{ 123 | PackagePath: testFile.Name(), 124 | Sinfs: []Sinf{ 125 | { 126 | ID: 0, 127 | Data: []byte(""), 128 | }, 129 | }, 130 | }) 131 | Expect(err).ToNot(HaveOccurred()) 132 | }) 133 | }) 134 | 135 | When("app does not include codesign manifest", func() { 136 | BeforeEach(func() { 137 | mockOS.EXPECT(). 138 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 139 | DoAndReturn(os.OpenFile) 140 | 141 | mockOS.EXPECT(). 142 | Remove(testFile.Name()). 143 | Return(nil) 144 | 145 | mockOS.EXPECT(). 146 | Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). 147 | Return(nil) 148 | 149 | w, err := testZip.Create("Payload/Test.app/Info.plist") 150 | Expect(err).ToNot(HaveOccurred()) 151 | 152 | info, err := plist.Marshal(map[string]interface{}{ 153 | "CFBundleExecutable": "Test", 154 | }, plist.BinaryFormat) 155 | Expect(err).ToNot(HaveOccurred()) 156 | 157 | _, err = w.Write(info) 158 | Expect(err).ToNot(HaveOccurred()) 159 | 160 | w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") 161 | Expect(err).ToNot(HaveOccurred()) 162 | 163 | watchInfo, err := plist.Marshal(map[string]interface{}{ 164 | "WKWatchKitApp": true, 165 | }, plist.BinaryFormat) 166 | Expect(err).ToNot(HaveOccurred()) 167 | 168 | _, err = w.Write(watchInfo) 169 | Expect(err).ToNot(HaveOccurred()) 170 | }) 171 | 172 | It("replicates sinf", func() { 173 | err := as.ReplicateSinf(ReplicateSinfInput{ 174 | PackagePath: testFile.Name(), 175 | Sinfs: []Sinf{ 176 | { 177 | ID: 0, 178 | Data: []byte(""), 179 | }, 180 | }, 181 | }) 182 | Expect(err).ToNot(HaveOccurred()) 183 | }) 184 | }) 185 | 186 | When("fails to open file", func() { 187 | BeforeEach(func() { 188 | mockOS.EXPECT(). 189 | OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). 190 | Return(nil, errors.New("")) 191 | }) 192 | 193 | It("returns error", func() { 194 | err := as.ReplicateSinf(ReplicateSinfInput{ 195 | PackagePath: testFile.Name(), 196 | }) 197 | Expect(err).To(HaveOccurred()) 198 | }) 199 | }) 200 | }) 201 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_revoke.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (t *appstore) Revoke() error { 8 | err := t.keychain.Remove("account") 9 | if err != nil { 10 | return fmt.Errorf("failed to remove account from keychain: %w", err) 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_revoke_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/majd/ipatool/v2/pkg/keychain" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | var _ = Describe("AppStore (Revoke)", func() { 13 | var ( 14 | ctrl *gomock.Controller 15 | appstore AppStore 16 | mockKeychain *keychain.MockKeychain 17 | ) 18 | 19 | BeforeEach(func() { 20 | ctrl = gomock.NewController(GinkgoT()) 21 | mockKeychain = keychain.NewMockKeychain(ctrl) 22 | appstore = NewAppStore(Args{ 23 | Keychain: mockKeychain, 24 | }) 25 | }) 26 | 27 | AfterEach(func() { 28 | ctrl.Finish() 29 | }) 30 | 31 | When("keychain removes item", func() { 32 | BeforeEach(func() { 33 | mockKeychain.EXPECT(). 34 | Remove("account"). 35 | Return(nil) 36 | }) 37 | 38 | It("returns data", func() { 39 | err := appstore.Revoke() 40 | Expect(err).ToNot(HaveOccurred()) 41 | }) 42 | }) 43 | 44 | When("keychain returns error", func() { 45 | BeforeEach(func() { 46 | mockKeychain.EXPECT(). 47 | Remove("account"). 48 | Return(errors.New("")) 49 | }) 50 | 51 | It("returns wrapped error", func() { 52 | err := appstore.Revoke() 53 | Expect(err).To(HaveOccurred()) 54 | }) 55 | }) 56 | }) 57 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_search.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | "fmt" 6 | gohttp "net/http" 7 | "net/url" 8 | "strconv" 9 | 10 | "github.com/majd/ipatool/v2/pkg/http" 11 | ) 12 | 13 | type SearchInput struct { 14 | Account Account 15 | Term string 16 | Limit int64 17 | } 18 | 19 | type SearchOutput struct { 20 | Count int 21 | Results []App 22 | } 23 | 24 | func (t *appstore) Search(input SearchInput) (SearchOutput, error) { 25 | countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) 26 | if err != nil { 27 | return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err) 28 | } 29 | 30 | request := t.searchRequest(input.Term, countryCode, input.Limit) 31 | 32 | res, err := t.searchClient.Send(request) 33 | if err != nil { 34 | return SearchOutput{}, fmt.Errorf("request failed: %w", err) 35 | } 36 | 37 | if res.StatusCode != gohttp.StatusOK { 38 | return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res) 39 | } 40 | 41 | return SearchOutput{ 42 | Count: res.Data.Count, 43 | Results: res.Data.Results, 44 | }, nil 45 | } 46 | 47 | type searchResult struct { 48 | Count int `json:"resultCount,omitempty"` 49 | Results []App `json:"results,omitempty"` 50 | } 51 | 52 | func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request { 53 | return http.Request{ 54 | URL: t.searchURL(term, countryCode, limit), 55 | Method: http.MethodGET, 56 | ResponseFormat: http.ResponseFormatJSON, 57 | } 58 | } 59 | 60 | func (t *appstore) searchURL(term, countryCode string, limit int64) string { 61 | params := url.Values{} 62 | params.Add("entity", "software,iPadSoftware") 63 | params.Add("limit", strconv.Itoa(int(limit))) 64 | params.Add("media", "software") 65 | params.Add("term", term) 66 | params.Add("country", countryCode) 67 | 68 | return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode()) 69 | } 70 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_search_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/majd/ipatool/v2/pkg/http" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | var _ = Describe("AppStore (Search)", func() { 13 | var ( 14 | ctrl *gomock.Controller 15 | mockClient *http.MockClient[searchResult] 16 | as AppStore 17 | ) 18 | 19 | BeforeEach(func() { 20 | ctrl = gomock.NewController(GinkgoT()) 21 | mockClient = http.NewMockClient[searchResult](ctrl) 22 | as = &appstore{ 23 | searchClient: mockClient, 24 | } 25 | }) 26 | 27 | AfterEach(func() { 28 | ctrl.Finish() 29 | }) 30 | 31 | When("request is successful", func() { 32 | const ( 33 | testID = 0 34 | testBundleID = "test-bundle-id" 35 | testName = "test-name" 36 | testVersion = "test-version" 37 | testPrice = 0.0 38 | ) 39 | 40 | BeforeEach(func() { 41 | mockClient.EXPECT(). 42 | Send(gomock.Any()). 43 | Return(http.Result[searchResult]{ 44 | StatusCode: 200, 45 | Data: searchResult{ 46 | Count: 1, 47 | Results: []App{ 48 | { 49 | ID: testID, 50 | BundleID: testBundleID, 51 | Name: testName, 52 | Version: testVersion, 53 | Price: testPrice, 54 | }, 55 | }, 56 | }, 57 | }, nil) 58 | }) 59 | 60 | It("returns output", func() { 61 | out, err := as.Search(SearchInput{ 62 | Account: Account{ 63 | StoreFront: "143441", 64 | }, 65 | }) 66 | Expect(err).ToNot(HaveOccurred()) 67 | Expect(out.Count).To(Equal(1)) 68 | Expect(out.Results).To(HaveLen(1)) 69 | Expect(out.Results[0]).To(Equal(App{ 70 | ID: testID, 71 | BundleID: testBundleID, 72 | Name: testName, 73 | Version: testVersion, 74 | Price: testPrice, 75 | })) 76 | }) 77 | }) 78 | 79 | When("store front is invalid", func() { 80 | It("returns error", func() { 81 | _, err := as.Search(SearchInput{ 82 | Account: Account{ 83 | StoreFront: "xyz", 84 | }, 85 | }) 86 | Expect(err).To(HaveOccurred()) 87 | }) 88 | }) 89 | 90 | When("request fails", func() { 91 | BeforeEach(func() { 92 | mockClient.EXPECT(). 93 | Send(gomock.Any()). 94 | Return(http.Result[searchResult]{}, errors.New("")) 95 | }) 96 | 97 | It("returns error", func() { 98 | _, err := as.Search(SearchInput{ 99 | Account: Account{ 100 | StoreFront: "143441", 101 | }, 102 | }) 103 | Expect(err).To(HaveOccurred()) 104 | }) 105 | }) 106 | 107 | When("request returns bad status code", func() { 108 | BeforeEach(func() { 109 | mockClient.EXPECT(). 110 | Send(gomock.Any()). 111 | Return(http.Result[searchResult]{ 112 | StatusCode: 400, 113 | }, nil) 114 | }) 115 | 116 | It("returns error", func() { 117 | _, err := as.Search(SearchInput{ 118 | Account: Account{ 119 | StoreFront: "143441", 120 | }, 121 | }) 122 | Expect(err).To(HaveOccurred()) 123 | }) 124 | }) 125 | }) 126 | -------------------------------------------------------------------------------- /pkg/appstore/appstore_test.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestAppStore(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "App Store Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/appstore/constants.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | const ( 4 | FailureTypeInvalidCredentials = "-5000" 5 | FailureTypePasswordTokenExpired = "2034" 6 | FailureTypeLicenseNotFound = "9610" 7 | FailureTypeTemporarilyUnavailable = "2059" 8 | 9 | CustomerMessageBadLogin = "MZFinance.BadLogin.Configurator_message" 10 | CustomerMessageSubscriptionRequired = "Subscription Required" 11 | 12 | iTunesAPIDomain = "itunes.apple.com" 13 | iTunesAPIPathSearch = "/search" 14 | iTunesAPIPathLookup = "/lookup" 15 | 16 | PrivateAppStoreAPIDomainPrefixWithoutAuthCode = "p25" 17 | PrivateAppStoreAPIDomainPrefixWithAuthCode = "p71" 18 | PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain 19 | PrivateAppStoreAPIPathAuthenticate = "/WebObjects/MZFinance.woa/wa/authenticate" 20 | PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" 21 | PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" 22 | 23 | HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" 24 | 25 | PricingParameterAppStore = "STDQ" 26 | PricingParameterAppleArcade = "GAME" 27 | ) 28 | -------------------------------------------------------------------------------- /pkg/appstore/error.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | type Error struct { 4 | Metadata interface{} 5 | underlyingError error 6 | } 7 | 8 | func (t Error) Error() string { 9 | return t.underlyingError.Error() 10 | } 11 | 12 | func NewErrorWithMetadata(err error, metadata interface{}) *Error { 13 | return &Error{ 14 | underlyingError: err, 15 | Metadata: metadata, 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /pkg/appstore/storefront.go: -------------------------------------------------------------------------------- 1 | package appstore 2 | 3 | import ( 4 | "fmt" 5 | "strings" 6 | ) 7 | 8 | func countryCodeFromStoreFront(storeFront string) (string, error) { 9 | for key, val := range storeFronts { 10 | parts := strings.Split(storeFront, "-") 11 | 12 | if len(parts) >= 1 && parts[0] == val { 13 | return key, nil 14 | } 15 | } 16 | 17 | return "", fmt.Errorf("country code mapping for store front (%s) was not found", storeFront) 18 | } 19 | 20 | var storeFronts = map[string]string{ 21 | "AE": "143481", 22 | "AG": "143540", 23 | "AI": "143538", 24 | "AL": "143575", 25 | "AM": "143524", 26 | "AO": "143564", 27 | "AR": "143505", 28 | "AT": "143445", 29 | "AU": "143460", 30 | "AZ": "143568", 31 | "BB": "143541", 32 | "BD": "143490", 33 | "BE": "143446", 34 | "BG": "143526", 35 | "BH": "143559", 36 | "BM": "143542", 37 | "BN": "143560", 38 | "BO": "143556", 39 | "BR": "143503", 40 | "BS": "143539", 41 | "BW": "143525", 42 | "BY": "143565", 43 | "BZ": "143555", 44 | "CA": "143455", 45 | "CH": "143459", 46 | "CI": "143527", 47 | "CL": "143483", 48 | "CN": "143465", 49 | "CO": "143501", 50 | "CR": "143495", 51 | "CY": "143557", 52 | "CZ": "143489", 53 | "DE": "143443", 54 | "DK": "143458", 55 | "DM": "143545", 56 | "DO": "143508", 57 | "DZ": "143563", 58 | "EC": "143509", 59 | "EE": "143518", 60 | "EG": "143516", 61 | "ES": "143454", 62 | "FI": "143447", 63 | "FR": "143442", 64 | "GB": "143444", 65 | "GD": "143546", 66 | "GE": "143615", 67 | "GH": "143573", 68 | "GR": "143448", 69 | "GT": "143504", 70 | "GY": "143553", 71 | "HK": "143463", 72 | "HN": "143510", 73 | "HR": "143494", 74 | "HU": "143482", 75 | "ID": "143476", 76 | "IE": "143449", 77 | "IL": "143491", 78 | "IN": "143467", 79 | "IS": "143558", 80 | "IT": "143450", 81 | "JM": "143511", 82 | "JO": "143528", 83 | "JP": "143462", 84 | "KE": "143529", 85 | "KN": "143548", 86 | "KR": "143466", 87 | "KW": "143493", 88 | "KY": "143544", 89 | "KZ": "143517", 90 | "LB": "143497", 91 | "LC": "143549", 92 | "LI": "143522", 93 | "LK": "143486", 94 | "LT": "143520", 95 | "LU": "143451", 96 | "LV": "143519", 97 | "MD": "143523", 98 | "MG": "143531", 99 | "MK": "143530", 100 | "ML": "143532", 101 | "MN": "143592", 102 | "MO": "143515", 103 | "MS": "143547", 104 | "MT": "143521", 105 | "MU": "143533", 106 | "MV": "143488", 107 | "MX": "143468", 108 | "MY": "143473", 109 | "NE": "143534", 110 | "NG": "143561", 111 | "NI": "143512", 112 | "NL": "143452", 113 | "NO": "143457", 114 | "NP": "143484", 115 | "NZ": "143461", 116 | "OM": "143562", 117 | "PA": "143485", 118 | "PE": "143507", 119 | "PH": "143474", 120 | "PK": "143477", 121 | "PL": "143478", 122 | "PT": "143453", 123 | "PY": "143513", 124 | "QA": "143498", 125 | "RO": "143487", 126 | "RS": "143500", 127 | "RU": "143469", 128 | "SA": "143479", 129 | "SE": "143456", 130 | "SG": "143464", 131 | "SI": "143499", 132 | "SK": "143496", 133 | "SN": "143535", 134 | "SR": "143554", 135 | "SV": "143506", 136 | "TC": "143552", 137 | "TH": "143475", 138 | "TN": "143536", 139 | "TR": "143480", 140 | "TT": "143551", 141 | "TW": "143470", 142 | "TZ": "143572", 143 | "UA": "143492", 144 | "UG": "143537", 145 | "US": "143441", 146 | "UY": "143514", 147 | "UZ": "143566", 148 | "VC": "143550", 149 | "VE": "143502", 150 | "VG": "143543", 151 | "VN": "143471", 152 | "YE": "143571", 153 | "ZA": "143472", 154 | } 155 | -------------------------------------------------------------------------------- /pkg/http/client.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "encoding/json" 6 | "fmt" 7 | "io" 8 | "net/http" 9 | "strings" 10 | 11 | "howett.net/plist" 12 | ) 13 | 14 | const ( 15 | appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" 16 | ) 17 | 18 | //go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http 19 | type Client[R interface{}] interface { 20 | Send(request Request) (Result[R], error) 21 | Do(req *http.Request) (*http.Response, error) 22 | NewRequest(method, url string, body io.Reader) (*http.Request, error) 23 | } 24 | 25 | type client[R interface{}] struct { 26 | internalClient http.Client 27 | cookieJar CookieJar 28 | } 29 | 30 | type Args struct { 31 | CookieJar CookieJar 32 | } 33 | 34 | type AddHeaderTransport struct { 35 | T http.RoundTripper 36 | } 37 | 38 | func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { 39 | if req.Header.Get("User-Agent") == "" { 40 | req.Header.Set("User-Agent", DefaultUserAgent) 41 | } 42 | 43 | res, err := t.T.RoundTrip(req) 44 | if err != nil { 45 | return nil, fmt.Errorf("failed to make round trip: %w", err) 46 | } 47 | 48 | return res, nil 49 | } 50 | 51 | func NewClient[R interface{}](args Args) Client[R] { 52 | return &client[R]{ 53 | internalClient: http.Client{ 54 | Timeout: 0, 55 | Jar: args.CookieJar, 56 | CheckRedirect: func(req *http.Request, via []*http.Request) error { 57 | if req.Referer() == appStoreAuthURL { 58 | return http.ErrUseLastResponse 59 | } 60 | 61 | return nil 62 | }, 63 | Transport: &AddHeaderTransport{http.DefaultTransport}, 64 | }, 65 | cookieJar: args.CookieJar, 66 | } 67 | } 68 | 69 | func (c *client[R]) Send(req Request) (Result[R], error) { 70 | var ( 71 | data []byte 72 | err error 73 | ) 74 | 75 | if req.Payload != nil { 76 | data, err = req.Payload.data() 77 | if err != nil { 78 | return Result[R]{}, fmt.Errorf("failed to get payload data: %w", err) 79 | } 80 | } 81 | 82 | request, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(data)) 83 | if err != nil { 84 | return Result[R]{}, fmt.Errorf("failed to create request: %w", err) 85 | } 86 | 87 | for key, val := range req.Headers { 88 | request.Header.Set(key, val) 89 | } 90 | 91 | res, err := c.internalClient.Do(request) 92 | if err != nil { 93 | return Result[R]{}, fmt.Errorf("request failed: %w", err) 94 | } 95 | defer res.Body.Close() 96 | 97 | err = c.cookieJar.Save() 98 | if err != nil { 99 | return Result[R]{}, fmt.Errorf("failed to save cookies: %w", err) 100 | } 101 | 102 | if req.ResponseFormat == ResponseFormatJSON { 103 | return c.handleJSONResponse(res) 104 | } 105 | 106 | if req.ResponseFormat == ResponseFormatXML { 107 | return c.handleXMLResponse(res) 108 | } 109 | 110 | return Result[R]{}, fmt.Errorf("content type is not supported (%s)", req.ResponseFormat) 111 | } 112 | 113 | func (c *client[R]) Do(req *http.Request) (*http.Response, error) { 114 | res, err := c.internalClient.Do(req) 115 | if err != nil { 116 | return nil, fmt.Errorf("received error: %w", err) 117 | } 118 | 119 | return res, nil 120 | } 121 | 122 | func (*client[R]) NewRequest(method, url string, body io.Reader) (*http.Request, error) { 123 | req, err := http.NewRequest(method, url, body) 124 | if err != nil { 125 | return nil, fmt.Errorf("failed to create request: %w", err) 126 | } 127 | 128 | return req, nil 129 | } 130 | 131 | func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) { 132 | body, err := io.ReadAll(res.Body) 133 | if err != nil { 134 | return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) 135 | } 136 | 137 | var data R 138 | 139 | err = json.Unmarshal(body, &data) 140 | if err != nil { 141 | return Result[R]{}, fmt.Errorf("failed to unmarshal json: %w", err) 142 | } 143 | 144 | return Result[R]{ 145 | StatusCode: res.StatusCode, 146 | Data: data, 147 | }, nil 148 | } 149 | 150 | func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { 151 | body, err := io.ReadAll(res.Body) 152 | if err != nil { 153 | return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) 154 | } 155 | 156 | var data R 157 | 158 | _, err = plist.Unmarshal(body, &data) 159 | if err != nil { 160 | return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err) 161 | } 162 | 163 | headers := map[string]string{} 164 | for key, val := range res.Header { 165 | headers[key] = strings.Join(val, "; ") 166 | } 167 | 168 | return Result[R]{ 169 | StatusCode: res.StatusCode, 170 | Headers: headers, 171 | Data: data, 172 | }, nil 173 | } 174 | -------------------------------------------------------------------------------- /pkg/http/client_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "net/http" 6 | "net/http/httptest" 7 | 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | var _ = Describe("Client", Ordered, func() { 14 | type jsonResult struct { 15 | Foo string `json:"foo"` 16 | } 17 | 18 | type xmlResult struct { 19 | Foo string `plist:"foo"` 20 | } 21 | 22 | var ( 23 | ctrl *gomock.Controller 24 | srv *httptest.Server 25 | mockHandler func(w http.ResponseWriter, r *http.Request) 26 | mockCookieJar *MockCookieJar 27 | ) 28 | 29 | BeforeAll(func() { 30 | ctrl = gomock.NewController(GinkgoT()) 31 | mockCookieJar = NewMockCookieJar(ctrl) 32 | mockHandler = func(w http.ResponseWriter, r *http.Request) {} 33 | srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 34 | mockHandler(w, r) 35 | })) 36 | }) 37 | 38 | BeforeEach(func() { 39 | mockCookieJar.EXPECT(). 40 | Cookies(gomock.Any()). 41 | Return(nil). 42 | MaxTimes(1) 43 | }) 44 | 45 | It("returns request", func() { 46 | sut := NewClient[xmlResult](Args{}) 47 | 48 | req, err := sut.NewRequest("GET", srv.URL, nil) 49 | Expect(err).ToNot(HaveOccurred()) 50 | Expect(req).ToNot(BeNil()) 51 | }) 52 | 53 | It("returns response", func() { 54 | mockHandler = func(_w http.ResponseWriter, r *http.Request) { 55 | defer GinkgoRecover() 56 | Expect(r.Header.Get("User-Agent")).To(Equal(DefaultUserAgent)) 57 | } 58 | 59 | sut := NewClient[xmlResult](Args{}) 60 | 61 | req, err := sut.NewRequest("GET", srv.URL, nil) 62 | Expect(err).ToNot(HaveOccurred()) 63 | Expect(req).ToNot(BeNil()) 64 | 65 | res, err := sut.Do(req) 66 | Expect(err).ToNot(HaveOccurred()) 67 | Expect(res).ToNot(BeNil()) 68 | }) 69 | 70 | When("payload decodes successfully", func() { 71 | When("cookie jar fails to save", func() { 72 | BeforeEach(func() { 73 | mockCookieJar.EXPECT(). 74 | Save(). 75 | Return(errors.New("")) 76 | }) 77 | 78 | It("returns error", func() { 79 | sut := NewClient[jsonResult](Args{ 80 | CookieJar: mockCookieJar, 81 | }) 82 | _, err := sut.Send(Request{ 83 | URL: srv.URL, 84 | Method: MethodGET, 85 | }) 86 | 87 | Expect(err).To(HaveOccurred()) 88 | }) 89 | }) 90 | 91 | When("cookie jar saves new cookies", func() { 92 | BeforeEach(func() { 93 | mockCookieJar.EXPECT(). 94 | Save(). 95 | Return(nil) 96 | }) 97 | 98 | It("decodes JSON response", func() { 99 | mockHandler = func(w http.ResponseWriter, _r *http.Request) { 100 | w.Header().Add("Content-Type", "application/json") 101 | _, err := w.Write([]byte("{\"foo\":\"bar\"}")) 102 | Expect(err).ToNot(HaveOccurred()) 103 | } 104 | 105 | sut := NewClient[jsonResult](Args{ 106 | CookieJar: mockCookieJar, 107 | }) 108 | res, err := sut.Send(Request{ 109 | URL: srv.URL, 110 | Method: MethodGET, 111 | ResponseFormat: ResponseFormatJSON, 112 | Headers: map[string]string{ 113 | "foo": "bar", 114 | }, 115 | Payload: &URLPayload{ 116 | Content: map[string]interface{}{ 117 | "data": "test", 118 | }, 119 | }, 120 | }) 121 | 122 | Expect(err).ToNot(HaveOccurred()) 123 | Expect(res.Data.Foo).To(Equal("bar")) 124 | }) 125 | 126 | It("decodes XML response", func() { 127 | mockHandler = func(w http.ResponseWriter, _r *http.Request) { 128 | w.Header().Add("Content-Type", "application/xml") 129 | _, err := w.Write([]byte("foobar")) 130 | Expect(err).ToNot(HaveOccurred()) 131 | } 132 | 133 | sut := NewClient[xmlResult](Args{ 134 | CookieJar: mockCookieJar, 135 | }) 136 | res, err := sut.Send(Request{ 137 | URL: srv.URL, 138 | Method: MethodPOST, 139 | ResponseFormat: ResponseFormatXML, 140 | }) 141 | 142 | Expect(err).ToNot(HaveOccurred()) 143 | Expect(res.Data.Foo).To(Equal("bar")) 144 | }) 145 | 146 | It("returns error when content type is not supported", func() { 147 | mockHandler = func(w http.ResponseWriter, _r *http.Request) { 148 | w.Header().Add("Content-Type", "application/xyz") 149 | } 150 | 151 | sut := NewClient[xmlResult](Args{ 152 | CookieJar: mockCookieJar, 153 | }) 154 | _, err := sut.Send(Request{ 155 | URL: srv.URL, 156 | Method: MethodPOST, 157 | ResponseFormat: "random", 158 | }) 159 | 160 | Expect(err).To(HaveOccurred()) 161 | }) 162 | }) 163 | }) 164 | 165 | When("payload fails to decode", func() { 166 | It("returns error", func() { 167 | sut := NewClient[xmlResult](Args{ 168 | CookieJar: mockCookieJar, 169 | }) 170 | _, err := sut.Send(Request{ 171 | URL: srv.URL, 172 | Method: MethodPOST, 173 | ResponseFormat: ResponseFormatXML, 174 | Payload: &URLPayload{ 175 | Content: map[string]interface{}{ 176 | "data": func() {}, 177 | }, 178 | }, 179 | }) 180 | 181 | Expect(err).To(HaveOccurred()) 182 | }) 183 | }) 184 | }) 185 | -------------------------------------------------------------------------------- /pkg/http/constants.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | type ResponseFormat string 4 | 5 | const ( 6 | ResponseFormatJSON ResponseFormat = "json" 7 | ResponseFormatXML ResponseFormat = "xml" 8 | ) 9 | 10 | const ( 11 | DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6" 12 | ) 13 | -------------------------------------------------------------------------------- /pkg/http/cookiejar.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import "net/http" 4 | 5 | //go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http 6 | type CookieJar interface { 7 | http.CookieJar 8 | 9 | Save() error 10 | } 11 | -------------------------------------------------------------------------------- /pkg/http/http_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestHTTP(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "HTTP Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/http/method.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | const ( 4 | MethodGET = "GET" 5 | MethodPOST = "POST" 6 | ) 7 | -------------------------------------------------------------------------------- /pkg/http/payload.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "bytes" 5 | "fmt" 6 | "net/url" 7 | "strconv" 8 | 9 | "howett.net/plist" 10 | ) 11 | 12 | type Payload interface { 13 | data() ([]byte, error) 14 | } 15 | 16 | type XMLPayload struct { 17 | Content map[string]interface{} 18 | } 19 | 20 | type URLPayload struct { 21 | Content map[string]interface{} 22 | } 23 | 24 | func (p *XMLPayload) data() ([]byte, error) { 25 | buffer := new(bytes.Buffer) 26 | 27 | err := plist.NewEncoder(buffer).Encode(p.Content) 28 | if err != nil { 29 | return nil, fmt.Errorf("failed to encode plist: %w", err) 30 | } 31 | 32 | return buffer.Bytes(), nil 33 | } 34 | 35 | func (p *URLPayload) data() ([]byte, error) { 36 | params := url.Values{} 37 | 38 | for key, val := range p.Content { 39 | switch t := val.(type) { 40 | case string: 41 | params.Add(key, val.(string)) 42 | case int: 43 | params.Add(key, strconv.Itoa(val.(int))) 44 | default: 45 | return nil, fmt.Errorf("value type is not supported (%s)", t) 46 | } 47 | } 48 | 49 | return []byte(params.Encode()), nil 50 | } 51 | -------------------------------------------------------------------------------- /pkg/http/payload_test.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Payload", func() { 9 | var sut Payload 10 | 11 | Context("URL Payload", func() { 12 | It("returns encoded URL data", func() { 13 | sut = &URLPayload{ 14 | Content: map[string]interface{}{ 15 | "foo": "bar", 16 | "num": 3, 17 | }, 18 | } 19 | 20 | data, err := sut.data() 21 | Expect(err).ToNot(HaveOccurred()) 22 | Expect(data).To(Equal([]byte("foo=bar&num=3"))) 23 | }) 24 | 25 | It("returns error if URL data is invalid", func() { 26 | sut = &URLPayload{ 27 | Content: map[string]interface{}{ 28 | "foo": func() {}, 29 | }, 30 | } 31 | 32 | data, err := sut.data() 33 | Expect(err).To(HaveOccurred()) 34 | Expect(data).To(BeNil()) 35 | }) 36 | }) 37 | 38 | Context("XML Payload", func() { 39 | It("returns encoded XML data", func() { 40 | sut = &XMLPayload{ 41 | Content: map[string]interface{}{ 42 | "foo": "bar", 43 | "lorem": "ipsum", 44 | }, 45 | } 46 | 47 | data, err := sut.data() 48 | Expect(err).ToNot(HaveOccurred()) 49 | Expect(data).To(ContainSubstring("foobarloremipsum")) 50 | }) 51 | 52 | It("returns error if XML data is invalid", func() { 53 | sut = &XMLPayload{ 54 | Content: map[string]interface{}{ 55 | "foo": func() {}, 56 | }, 57 | } 58 | 59 | data, err := sut.data() 60 | Expect(err).To(HaveOccurred()) 61 | Expect(data).To(BeNil()) 62 | }) 63 | }) 64 | }) 65 | -------------------------------------------------------------------------------- /pkg/http/request.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | type Request struct { 4 | Method string 5 | URL string 6 | Headers map[string]string 7 | Payload Payload 8 | ResponseFormat ResponseFormat 9 | } 10 | -------------------------------------------------------------------------------- /pkg/http/result.go: -------------------------------------------------------------------------------- 1 | package http 2 | 3 | import ( 4 | "errors" 5 | "strings" 6 | ) 7 | 8 | var ( 9 | ErrHeaderNotFound = errors.New("header not found") 10 | ) 11 | 12 | type Result[R interface{}] struct { 13 | StatusCode int 14 | Headers map[string]string 15 | Data R 16 | } 17 | 18 | func (c *Result[R]) GetHeader(key string) (string, error) { 19 | key = strings.ToLower(key) 20 | for k, v := range c.Headers { 21 | if strings.ToLower(k) == key { 22 | return v, nil 23 | } 24 | } 25 | 26 | return "", ErrHeaderNotFound 27 | } 28 | -------------------------------------------------------------------------------- /pkg/keychain/keychain.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | //go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -package keychain 4 | type Keychain interface { 5 | Get(key string) ([]byte, error) 6 | Set(key string, data []byte) error 7 | Remove(key string) error 8 | } 9 | 10 | type keychain struct { 11 | keyring Keyring 12 | } 13 | 14 | type Args struct { 15 | Keyring Keyring 16 | } 17 | 18 | func New(args Args) Keychain { 19 | return &keychain{ 20 | keyring: args.Keyring, 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_get.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (k *keychain) Get(key string) ([]byte, error) { 8 | item, err := k.keyring.Get(key) 9 | if err != nil { 10 | return nil, fmt.Errorf("failed to get item: %w", err) 11 | } 12 | 13 | return item.Data, nil 14 | } 15 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_get_test.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/99designs/keyring" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | var _ = Describe("Keychain (Get)", func() { 13 | var ( 14 | ctrl *gomock.Controller 15 | keychain Keychain 16 | mockKeyring *MockKeyring 17 | ) 18 | 19 | BeforeEach(func() { 20 | ctrl = gomock.NewController(GinkgoT()) 21 | mockKeyring = NewMockKeyring(ctrl) 22 | keychain = New(Args{ 23 | Keyring: mockKeyring, 24 | }) 25 | }) 26 | 27 | AfterEach(func() { 28 | ctrl.Finish() 29 | }) 30 | 31 | When("keyring returns error", func() { 32 | const testKey = "test-key" 33 | 34 | BeforeEach(func() { 35 | mockKeyring.EXPECT(). 36 | Get(testKey). 37 | Return(keyring.Item{}, errors.New("")) 38 | }) 39 | 40 | It("returns wrapped error", func() { 41 | data, err := keychain.Get(testKey) 42 | Expect(err).To(HaveOccurred()) 43 | Expect(data).To(BeNil()) 44 | }) 45 | }) 46 | 47 | When("keyring returns item", func() { 48 | const testKey = "test-key" 49 | var testData = []byte("test") 50 | 51 | BeforeEach(func() { 52 | mockKeyring.EXPECT(). 53 | Get(testKey). 54 | Return(keyring.Item{ 55 | Data: testData, 56 | }, nil) 57 | }) 58 | 59 | It("returns data", func() { 60 | data, err := keychain.Get(testKey) 61 | Expect(err).ToNot(HaveOccurred()) 62 | Expect(data).To(Equal(testData)) 63 | }) 64 | }) 65 | }) 66 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_remove.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "fmt" 5 | ) 6 | 7 | func (k *keychain) Remove(key string) error { 8 | err := k.keyring.Remove(key) 9 | if err != nil { 10 | return fmt.Errorf("failed to remove item: %w", err) 11 | } 12 | 13 | return nil 14 | } 15 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_remove_test.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "errors" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | "go.uber.org/mock/gomock" 9 | ) 10 | 11 | var _ = Describe("Keychain (Remove)", func() { 12 | var ( 13 | ctrl *gomock.Controller 14 | keychain Keychain 15 | mockKeyring *MockKeyring 16 | ) 17 | 18 | BeforeEach(func() { 19 | ctrl = gomock.NewController(GinkgoT()) 20 | mockKeyring = NewMockKeyring(ctrl) 21 | keychain = New(Args{ 22 | Keyring: mockKeyring, 23 | }) 24 | }) 25 | 26 | AfterEach(func() { 27 | ctrl.Finish() 28 | }) 29 | 30 | When("keyring returns error", func() { 31 | const testKey = "test-key" 32 | 33 | BeforeEach(func() { 34 | mockKeyring.EXPECT(). 35 | Remove(testKey). 36 | Return(errors.New("")) 37 | }) 38 | 39 | It("returns wrapped error", func() { 40 | err := keychain.Remove(testKey) 41 | Expect(err).To(HaveOccurred()) 42 | }) 43 | }) 44 | 45 | When("keyring does not return error", func() { 46 | const testKey = "test-key" 47 | 48 | BeforeEach(func() { 49 | mockKeyring.EXPECT(). 50 | Remove(testKey). 51 | Return(nil) 52 | }) 53 | 54 | It("returns data", func() { 55 | err := keychain.Remove(testKey) 56 | Expect(err).ToNot(HaveOccurred()) 57 | }) 58 | }) 59 | }) 60 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_set.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "fmt" 5 | 6 | "github.com/99designs/keyring" 7 | ) 8 | 9 | func (k *keychain) Set(key string, data []byte) error { 10 | err := k.keyring.Set(keyring.Item{ 11 | Key: key, 12 | Data: data, 13 | }) 14 | if err != nil { 15 | return fmt.Errorf("failed to set item: %w", err) 16 | } 17 | 18 | return nil 19 | } 20 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_set_test.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "errors" 5 | 6 | "github.com/99designs/keyring" 7 | . "github.com/onsi/ginkgo/v2" 8 | . "github.com/onsi/gomega" 9 | "go.uber.org/mock/gomock" 10 | ) 11 | 12 | var _ = Describe("Keychain (Set)", func() { 13 | var ( 14 | ctrl *gomock.Controller 15 | keychain Keychain 16 | mockKeyring *MockKeyring 17 | ) 18 | 19 | BeforeEach(func() { 20 | ctrl = gomock.NewController(GinkgoT()) 21 | mockKeyring = NewMockKeyring(ctrl) 22 | keychain = New(Args{ 23 | Keyring: mockKeyring, 24 | }) 25 | }) 26 | 27 | AfterEach(func() { 28 | ctrl.Finish() 29 | }) 30 | 31 | When("keyring returns error", func() { 32 | const testKey = "test-key" 33 | var testData = []byte("test") 34 | 35 | BeforeEach(func() { 36 | mockKeyring.EXPECT(). 37 | Set(keyring.Item{ 38 | Key: testKey, 39 | Data: testData, 40 | }). 41 | Return(errors.New("")) 42 | }) 43 | 44 | It("returns wrapped error", func() { 45 | err := keychain.Set(testKey, testData) 46 | Expect(err).To(HaveOccurred()) 47 | }) 48 | }) 49 | 50 | When("keyring does not return error", func() { 51 | const testKey = "test-key" 52 | var testData = []byte("test") 53 | 54 | BeforeEach(func() { 55 | mockKeyring.EXPECT(). 56 | Set(keyring.Item{ 57 | Key: testKey, 58 | Data: testData, 59 | }). 60 | Return(nil) 61 | }) 62 | 63 | It("returns nil", func() { 64 | err := keychain.Set(testKey, testData) 65 | Expect(err).ToNot(HaveOccurred()) 66 | }) 67 | }) 68 | }) 69 | -------------------------------------------------------------------------------- /pkg/keychain/keychain_test.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestKeychain(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Keychain Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/keychain/keyring.go: -------------------------------------------------------------------------------- 1 | package keychain 2 | 3 | import "github.com/99designs/keyring" 4 | 5 | //go:generate go run go.uber.org/mock/mockgen -source=keyring.go -destination=keyring_mock.go -package keychain 6 | type Keyring interface { 7 | Get(key string) (keyring.Item, error) 8 | Set(item keyring.Item) error 9 | Remove(key string) error 10 | } 11 | -------------------------------------------------------------------------------- /pkg/log/log_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestLog(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Log Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/log/logger.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "io" 5 | 6 | "github.com/rs/zerolog" 7 | "github.com/rs/zerolog/log" 8 | "github.com/rs/zerolog/pkgerrors" 9 | ) 10 | 11 | //go:generate go run go.uber.org/mock/mockgen -source=logger.go -destination=logger_mock.go -package log 12 | type Logger interface { 13 | Verbose() *zerolog.Event 14 | Log() *zerolog.Event 15 | Error() *zerolog.Event 16 | } 17 | 18 | type logger struct { 19 | internalLogger zerolog.Logger 20 | verbose bool 21 | } 22 | 23 | type Args struct { 24 | Verbose bool 25 | Writer io.Writer 26 | } 27 | 28 | func NewLogger(args Args) Logger { 29 | internalLogger := log.Logger 30 | level := zerolog.InfoLevel 31 | 32 | if args.Verbose { 33 | level = zerolog.DebugLevel 34 | zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack 35 | } 36 | 37 | internalLogger = internalLogger.Output(args.Writer).Level(level) 38 | 39 | return &logger{ 40 | verbose: args.Verbose, 41 | internalLogger: internalLogger, 42 | } 43 | } 44 | 45 | func (l *logger) Log() *zerolog.Event { 46 | return l.internalLogger.Info() 47 | } 48 | 49 | func (l *logger) Verbose() *zerolog.Event { 50 | if !l.verbose { 51 | return nil 52 | } 53 | 54 | return l.internalLogger.Debug() 55 | } 56 | 57 | func (l *logger) Error() *zerolog.Event { 58 | return l.internalLogger.Error() 59 | } 60 | -------------------------------------------------------------------------------- /pkg/log/logger_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | 7 | "github.com/rs/zerolog" 8 | "go.uber.org/mock/gomock" 9 | ) 10 | 11 | var _ = Describe("Logger", func() { 12 | var ( 13 | ctrl *gomock.Controller 14 | mockWriter *MockWriter 15 | logger Logger 16 | ) 17 | 18 | BeforeEach(func() { 19 | ctrl = gomock.NewController(GinkgoT()) 20 | mockWriter = NewMockWriter(ctrl) 21 | }) 22 | 23 | Context("Verbose logger", func() { 24 | BeforeEach(func() { 25 | logger = NewLogger(Args{ 26 | Verbose: true, 27 | Writer: mockWriter, 28 | }) 29 | }) 30 | 31 | When("logging with verbose level", func() { 32 | It("writes output", func() { 33 | mockWriter.EXPECT(). 34 | WriteLevel(zerolog.DebugLevel, gomock.Any()). 35 | Do(func(level zerolog.Level, p []byte) { 36 | Expect(p).To(ContainSubstring("\"message\":\"verbose\"")) 37 | }). 38 | Return(0, nil) 39 | 40 | logger.Verbose().Msg("verbose") 41 | }) 42 | }) 43 | }) 44 | 45 | Context("Non-verbose logger", func() { 46 | BeforeEach(func() { 47 | logger = NewLogger(Args{ 48 | Verbose: false, 49 | Writer: mockWriter, 50 | }) 51 | }) 52 | 53 | When("logging messsage", func() { 54 | It("writes output", func() { 55 | mockWriter.EXPECT(). 56 | WriteLevel(zerolog.InfoLevel, gomock.Any()). 57 | Do(func(level zerolog.Level, p []byte) { 58 | Expect(p).To(ContainSubstring("\"message\":\"info\"")) 59 | }). 60 | Return(0, nil) 61 | 62 | logger.Log().Msg("info") 63 | }) 64 | }) 65 | 66 | When("logging error", func() { 67 | It("writes output", func() { 68 | mockWriter.EXPECT(). 69 | WriteLevel(zerolog.ErrorLevel, gomock.Any()). 70 | Do(func(level zerolog.Level, p []byte) { 71 | Expect(p).To(ContainSubstring("\"message\":\"error\"")) 72 | }). 73 | Return(0, nil) 74 | 75 | logger.Error().Msg("error") 76 | }) 77 | }) 78 | 79 | When("logging with verbose level", func() { 80 | It("returns nil", func() { 81 | res := logger.Verbose() 82 | Expect(res).To(BeNil()) 83 | }) 84 | }) 85 | }) 86 | }) 87 | -------------------------------------------------------------------------------- /pkg/log/writer.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | "fmt" 5 | "io" 6 | "os" 7 | 8 | "github.com/rs/zerolog" 9 | ) 10 | 11 | //go:generate go run go.uber.org/mock/mockgen -source=writer.go -destination=writer_mock.go -package log 12 | type Writer interface { 13 | Write(p []byte) (n int, err error) 14 | WriteLevel(level zerolog.Level, p []byte) (n int, err error) 15 | } 16 | 17 | type writer struct { 18 | stdOutWriter io.Writer 19 | stdErrWriter io.Writer 20 | } 21 | 22 | func NewWriter() Writer { 23 | return &writer{ 24 | stdOutWriter: zerolog.ConsoleWriter{Out: os.Stdout}, 25 | stdErrWriter: zerolog.ConsoleWriter{Out: os.Stderr}, 26 | } 27 | } 28 | 29 | func (l *writer) Write(p []byte) (int, error) { 30 | n, err := l.stdOutWriter.Write(p) 31 | if err != nil { 32 | return 0, fmt.Errorf("failed to write data: %w", err) 33 | } 34 | 35 | return n, nil 36 | } 37 | 38 | func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) { 39 | switch level { 40 | case zerolog.DebugLevel, zerolog.InfoLevel, zerolog.WarnLevel: 41 | n, err := l.stdOutWriter.Write(p) 42 | if err != nil { 43 | return 0, fmt.Errorf("failed to write data: %w", err) 44 | } 45 | 46 | return n, nil 47 | case zerolog.ErrorLevel: 48 | n, err := l.stdErrWriter.Write(p) 49 | if err != nil { 50 | return 0, fmt.Errorf("failed to write data: %w", err) 51 | } 52 | 53 | return n, nil 54 | default: 55 | return len(p), nil 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /pkg/log/writer_test.go: -------------------------------------------------------------------------------- 1 | package log 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | "github.com/rs/zerolog" 7 | "go.uber.org/mock/gomock" 8 | ) 9 | 10 | var _ = Describe("Writer", func() { 11 | var ( 12 | ctrl *gomock.Controller 13 | mockStdoutWriter *MockWriter 14 | mockStderrWriter *MockWriter 15 | sut *writer 16 | ) 17 | 18 | BeforeEach(func() { 19 | ctrl = gomock.NewController(GinkgoT()) 20 | mockStdoutWriter = NewMockWriter(ctrl) 21 | mockStderrWriter = NewMockWriter(ctrl) 22 | sut = &writer{ 23 | stdOutWriter: mockStdoutWriter, 24 | stdErrWriter: mockStderrWriter, 25 | } 26 | }) 27 | 28 | It("returns valid writer", func() { 29 | out := NewWriter() 30 | Expect(out).ToNot(BeNil()) 31 | }) 32 | 33 | When("writing logs", func() { 34 | It("writes debug logs to stdout", func() { 35 | mockStdoutWriter.EXPECT().Write([]byte("debug")).Return(0, nil) 36 | 37 | _, err := sut.WriteLevel(zerolog.DebugLevel, []byte("debug")) 38 | Expect(err).ToNot(HaveOccurred()) 39 | }) 40 | 41 | It("writes info logs to stdout", func() { 42 | mockStdoutWriter.EXPECT().Write([]byte("info")).Return(0, nil).Times(2) 43 | 44 | _, err := sut.Write([]byte("info")) 45 | Expect(err).ToNot(HaveOccurred()) 46 | 47 | _, err = sut.WriteLevel(zerolog.InfoLevel, []byte("info")) 48 | Expect(err).ToNot(HaveOccurred()) 49 | }) 50 | 51 | It("writes warn logs to stdout", func() { 52 | mockStdoutWriter.EXPECT().Write([]byte("warning")).Return(0, nil) 53 | 54 | _, err := sut.WriteLevel(zerolog.WarnLevel, []byte("warning")) 55 | Expect(err).ToNot(HaveOccurred()) 56 | }) 57 | 58 | It("writes error logs to stderr", func() { 59 | mockStderrWriter.EXPECT().Write([]byte("error")).Return(0, nil) 60 | 61 | _, err := sut.WriteLevel(zerolog.ErrorLevel, []byte("error")) 62 | Expect(err).ToNot(HaveOccurred()) 63 | }) 64 | }) 65 | 66 | When("log level is not supported", func() { 67 | It("returns the length of the passed log", func() { 68 | length, err := sut.WriteLevel(zerolog.PanicLevel, []byte("panic")) 69 | Expect(err).ToNot(HaveOccurred()) 70 | Expect(length).To(Equal(5)) 71 | }) 72 | }) 73 | }) 74 | -------------------------------------------------------------------------------- /pkg/util/machine/machine.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "fmt" 5 | "net" 6 | "path/filepath" 7 | "runtime" 8 | 9 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 10 | "golang.org/x/term" 11 | ) 12 | 13 | //go:generate go run go.uber.org/mock/mockgen -source=machine.go -destination=machine_mock.go -package machine 14 | type Machine interface { 15 | MacAddress() (string, error) 16 | HomeDirectory() string 17 | ReadPassword(fd int) ([]byte, error) 18 | } 19 | 20 | type machine struct { 21 | os operatingsystem.OperatingSystem 22 | } 23 | 24 | type Args struct { 25 | OS operatingsystem.OperatingSystem 26 | } 27 | 28 | func New(args Args) Machine { 29 | return &machine{ 30 | os: args.OS, 31 | } 32 | } 33 | 34 | func (*machine) MacAddress() (string, error) { 35 | interfaces, err := net.Interfaces() 36 | if err != nil { 37 | return "", fmt.Errorf("failed to get network interfaces: %w", err) 38 | } 39 | 40 | if len(interfaces) == 0 { 41 | return "", fmt.Errorf("could not find network interfaces: %w", err) 42 | } 43 | 44 | for _, netInterface := range interfaces { 45 | addr := netInterface.HardwareAddr.String() 46 | if addr != "" { 47 | return addr, nil 48 | } 49 | } 50 | 51 | return "", fmt.Errorf("could not find network interfaces with a valid mac address: %w", err) 52 | } 53 | 54 | func (m *machine) HomeDirectory() string { 55 | if runtime.GOOS == "windows" { 56 | return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH")) 57 | } 58 | 59 | return m.os.Getenv("HOME") 60 | } 61 | 62 | func (*machine) ReadPassword(fd int) ([]byte, error) { 63 | data, err := term.ReadPassword(fd) 64 | if err != nil { 65 | return nil, fmt.Errorf("failed to read password: %w", err) 66 | } 67 | 68 | return data, nil 69 | } 70 | -------------------------------------------------------------------------------- /pkg/util/machine/machine_test.go: -------------------------------------------------------------------------------- 1 | package machine 2 | 3 | import ( 4 | "syscall" 5 | "testing" 6 | 7 | "github.com/majd/ipatool/v2/pkg/util/operatingsystem" 8 | . "github.com/onsi/ginkgo/v2" 9 | . "github.com/onsi/gomega" 10 | "go.uber.org/mock/gomock" 11 | ) 12 | 13 | func TestMachine(t *testing.T) { 14 | RegisterFailHandler(Fail) 15 | RunSpecs(t, "Machine Suite") 16 | } 17 | 18 | var _ = Describe("Machine", func() { 19 | var ( 20 | ctrl *gomock.Controller 21 | machine Machine 22 | mockOS *operatingsystem.MockOperatingSystem 23 | ) 24 | 25 | BeforeEach(func() { 26 | ctrl = gomock.NewController(GinkgoT()) 27 | mockOS = operatingsystem.NewMockOperatingSystem(ctrl) 28 | machine = New(Args{ 29 | OS: mockOS, 30 | }) 31 | }) 32 | 33 | When("OperatingSystem is darwin", func() { 34 | BeforeEach(func() { 35 | mockOS.EXPECT(). 36 | Getenv("HOME"). 37 | Return("/home/test") 38 | }) 39 | 40 | It("returns home directory from HOME", func() { 41 | dir := machine.HomeDirectory() 42 | Expect(dir).To(Equal("/home/test")) 43 | }) 44 | }) 45 | 46 | When("machine has network interfaces", func() { 47 | It("returns MAC address of the first interface", func() { 48 | res, err := machine.MacAddress() 49 | Expect(err).ToNot(HaveOccurred()) 50 | Expect(res).To(ContainSubstring(":")) 51 | }) 52 | }) 53 | 54 | When("reading password from stdout", func() { 55 | It("returns error", func() { 56 | _, err := machine.ReadPassword(syscall.Stdout) 57 | Expect(err).To(HaveOccurred()) 58 | }) 59 | }) 60 | }) 61 | -------------------------------------------------------------------------------- /pkg/util/must.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | // Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-nil. 4 | func Must[T any](val T, err error) T { 5 | if err != nil { 6 | panic(err.Error()) 7 | } 8 | 9 | return val 10 | } 11 | -------------------------------------------------------------------------------- /pkg/util/must_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "errors" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | var _ = Describe("Must", func() { 11 | It("returns current value", func() { 12 | res := Must("value", nil) 13 | Expect(res).To(Equal("value")) 14 | }) 15 | 16 | It("panics", func() { 17 | defer func() { 18 | r := recover() 19 | Expect(r).To(Equal("test")) 20 | }() 21 | 22 | _ = Must("value", errors.New("test")) 23 | }) 24 | }) 25 | -------------------------------------------------------------------------------- /pkg/util/operatingsystem/operatingsystem.go: -------------------------------------------------------------------------------- 1 | package operatingsystem 2 | 3 | import ( 4 | "os" 5 | ) 6 | 7 | //go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -destination=operatingsystem_mock.go -package operatingsystem 8 | type OperatingSystem interface { 9 | Getenv(key string) string 10 | Stat(name string) (os.FileInfo, error) 11 | Getwd() (string, error) 12 | OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) 13 | Remove(name string) error 14 | IsNotExist(err error) bool 15 | MkdirAll(path string, perm os.FileMode) error 16 | Rename(oldPath, newPath string) error 17 | } 18 | 19 | type operatingSystem struct{} 20 | 21 | func New() OperatingSystem { 22 | return &operatingSystem{} 23 | } 24 | 25 | func (operatingSystem) Getenv(key string) string { 26 | return os.Getenv(key) 27 | } 28 | 29 | // nolint:wrapcheck 30 | func (operatingSystem) Stat(name string) (os.FileInfo, error) { 31 | return os.Stat(name) 32 | } 33 | 34 | // nolint:wrapcheck 35 | func (operatingSystem) Getwd() (string, error) { 36 | return os.Getwd() 37 | } 38 | 39 | // nolint:wrapcheck 40 | func (operatingSystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { 41 | return os.OpenFile(name, flag, perm) 42 | } 43 | 44 | // nolint:wrapcheck 45 | func (operatingSystem) Remove(name string) error { 46 | return os.Remove(name) 47 | } 48 | 49 | // nolint:wrapcheck 50 | func (operatingSystem) IsNotExist(err error) bool { 51 | return os.IsNotExist(err) 52 | } 53 | 54 | // nolint:wrapcheck 55 | func (operatingSystem) MkdirAll(path string, perm os.FileMode) error { 56 | return os.MkdirAll(path, perm) 57 | } 58 | 59 | // nolint:wrapcheck 60 | func (operatingSystem) Rename(oldPath, newPath string) error { 61 | return os.Rename(oldPath, newPath) 62 | } 63 | -------------------------------------------------------------------------------- /pkg/util/operatingsystem/operatingsystem_test.go: -------------------------------------------------------------------------------- 1 | package operatingsystem 2 | 3 | import ( 4 | "fmt" 5 | "io/fs" 6 | "math/rand" 7 | "os" 8 | "path" 9 | "testing" 10 | "time" 11 | 12 | . "github.com/onsi/ginkgo/v2" 13 | . "github.com/onsi/gomega" 14 | ) 15 | 16 | func TestOS(t *testing.T) { 17 | RegisterFailHandler(Fail) 18 | RunSpecs(t, "OperatingSystem Suite") 19 | } 20 | 21 | var _ = Describe("OperatingSystem", func() { 22 | var sut OperatingSystem 23 | 24 | BeforeEach(func() { 25 | sut = New() 26 | }) 27 | 28 | When("env var is set", func() { 29 | BeforeEach(func() { 30 | err := os.Setenv("TEST", "true") 31 | Expect(err).ToNot(HaveOccurred()) 32 | }) 33 | 34 | It("returns env var", func() { 35 | res := sut.Getenv("TEST") 36 | Expect(res).To(Equal("true")) 37 | }) 38 | }) 39 | 40 | When("file exists", func() { 41 | var file *os.File 42 | 43 | BeforeEach(func() { 44 | var err error 45 | 46 | file, err = os.CreateTemp("", "test_file") 47 | Expect(err).ToNot(HaveOccurred()) 48 | }) 49 | 50 | AfterEach(func() { 51 | err := file.Close() 52 | Expect(err).ToNot(HaveOccurred()) 53 | }) 54 | 55 | It("returns file info", func() { 56 | res, err := sut.Stat(file.Name()) 57 | Expect(err).ToNot(HaveOccurred()) 58 | Expect(res.Name()).To(Equal(path.Base(file.Name()))) 59 | }) 60 | 61 | It("opens file", func() { 62 | res, err := sut.OpenFile(file.Name(), os.O_WRONLY, 0644) 63 | Expect(err).ToNot(HaveOccurred()) 64 | Expect(res.Name()).To(Equal(file.Name())) 65 | }) 66 | 67 | It("removes file", func() { 68 | err := sut.Remove(file.Name()) 69 | Expect(err).ToNot(HaveOccurred()) 70 | 71 | _, err = sut.Stat(file.Name()) 72 | Expect(os.IsNotExist(err)).To(BeTrue()) 73 | }) 74 | 75 | It("renames file", func() { 76 | r := rand.New(rand.NewSource(time.Now().UnixNano())) 77 | newPath := fmt.Sprintf("%s/%d", os.TempDir(), r.Intn(100)) 78 | 79 | err := sut.Rename(file.Name(), newPath) 80 | defer func() { 81 | _ = sut.Remove(newPath) 82 | }() 83 | 84 | Expect(err).ToNot(HaveOccurred()) 85 | }) 86 | }) 87 | 88 | When("running", func() { 89 | It("returns current working directory", func() { 90 | res, err := sut.Getwd() 91 | Expect(err).ToNot(HaveOccurred()) 92 | Expect(res).ToNot(BeNil()) 93 | }) 94 | }) 95 | 96 | When("error is 'ErrNotExist'", func() { 97 | It("returns true", func() { 98 | res := sut.IsNotExist(fs.ErrNotExist) 99 | Expect(res).To(BeTrue()) 100 | }) 101 | }) 102 | 103 | When("directory does not exist", func() { 104 | It("creates directory", func() { 105 | err := sut.MkdirAll(os.TempDir(), 0664) 106 | Expect(err).ToNot(HaveOccurred()) 107 | }) 108 | }) 109 | }) 110 | -------------------------------------------------------------------------------- /pkg/util/string.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | func IfEmpty(value, fallback string) string { 4 | if value == "" { 5 | return fallback 6 | } 7 | 8 | return value 9 | } 10 | -------------------------------------------------------------------------------- /pkg/util/string_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("String", func() { 9 | It("returns current value", func() { 10 | res := IfEmpty("current", "fallback") 11 | Expect(res).To(Equal("current")) 12 | }) 13 | 14 | It("returns fallback value", func() { 15 | res := IfEmpty("", "fallback") 16 | Expect(res).To(Equal("fallback")) 17 | }) 18 | }) 19 | -------------------------------------------------------------------------------- /pkg/util/util_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | "testing" 5 | 6 | . "github.com/onsi/ginkgo/v2" 7 | . "github.com/onsi/gomega" 8 | ) 9 | 10 | func TestUtil(t *testing.T) { 11 | RegisterFailHandler(Fail) 12 | RunSpecs(t, "Util Suite") 13 | } 14 | -------------------------------------------------------------------------------- /pkg/util/zip.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import "errors" 4 | 5 | type Pair[T, U any] struct { 6 | First T 7 | Second U 8 | } 9 | 10 | func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) { 11 | if len(ts) != len(us) { 12 | return nil, errors.New("slices have different lengths") 13 | } 14 | 15 | pairs := make([]Pair[T, U], len(ts)) 16 | for i := 0; i < len(ts); i++ { 17 | pairs[i] = Pair[T, U]{ 18 | First: ts[i], 19 | Second: us[i], 20 | } 21 | } 22 | 23 | return pairs, nil 24 | } 25 | -------------------------------------------------------------------------------- /pkg/util/zip_test.go: -------------------------------------------------------------------------------- 1 | package util 2 | 3 | import ( 4 | . "github.com/onsi/ginkgo/v2" 5 | . "github.com/onsi/gomega" 6 | ) 7 | 8 | var _ = Describe("Zip", func() { 9 | When("slices have different lengths", func() { 10 | It("returns error", func() { 11 | _, err := Zip([]string{}, []string{"test"}) 12 | Expect(err).To(HaveOccurred()) 13 | }) 14 | }) 15 | 16 | When("slices have different lengths", func() { 17 | It("returns zipped slices", func() { 18 | res, err := Zip([]string{ 19 | "lslice1", 20 | "lslice2", 21 | }, []string{ 22 | "rslice1", 23 | "rslice2", 24 | }) 25 | Expect(err).ToNot(HaveOccurred()) 26 | Expect(res[0].First).To(Equal("lslice1")) 27 | Expect(res[0].Second).To(Equal("rslice1")) 28 | Expect(res[1].First).To(Equal("lslice2")) 29 | Expect(res[1].Second).To(Equal("rslice2")) 30 | }) 31 | }) 32 | }) 33 | -------------------------------------------------------------------------------- /tools.go: -------------------------------------------------------------------------------- 1 | //go:build tools 2 | 3 | package main 4 | 5 | import ( 6 | _ "go.uber.org/mock/mockgen" 7 | ) 8 | -------------------------------------------------------------------------------- /tools/sha256sum.sh: -------------------------------------------------------------------------------- 1 | #!/bin/sh -e 2 | 3 | if which sha256sum >/dev/null 2>&1; then 4 | sha256sum "$1" | awk '{ print $1 }' 5 | else 6 | shasum -a256 "$1" | awk '{ print $1 }' 7 | fi --------------------------------------------------------------------------------