Repository: majd/ipatool Branch: main Commit: e91415603665 Files: 93 Total size: 189.3 KB Directory structure: gitextract_bcs5uo8x/ ├── .github/ │ ├── FUNDING.yml │ ├── 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 ├── AGENTS.md ├── LICENSE ├── README.md ├── cmd/ │ ├── auth.go │ ├── common.go │ ├── constants.go │ ├── download.go │ ├── get_version_metadata.go │ ├── list_versions.go │ ├── output_format.go │ ├── purchase.go │ ├── root.go │ └── search.go ├── 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_bag.go │ │ ├── appstore_bag_test.go │ │ ├── appstore_download.go │ │ ├── appstore_download_test.go │ │ ├── appstore_get_version_metadata.go │ │ ├── appstore_get_version_metadata_test.go │ │ ├── appstore_list_versions.go │ │ ├── appstore_list_versions_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/ │ └── sha256sum.sh └── tools.go ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/FUNDING.yml ================================================ # github: [majd] patreon: majd_dev ================================================ FILE: .github/ISSUE_TEMPLATE/bug-report.yaml ================================================ name: Bug Report description: File a bug report labels: - bug body: - type: textarea id: description attributes: label: What happened? description: Also share, what did you expect to happen? validations: required: true - type: input id: version attributes: label: Version description: What version of ipatool are you running? validations: required: true - type: textarea id: logs attributes: label: Relevant log output description: Please copy and paste any relevant log output. This will be automatically formatted into code, so no need for backticks. render: shell ================================================ FILE: .github/ISSUE_TEMPLATE/feature-request.yaml ================================================ name: Feature Request description: Submit a feature request labels: - feature request body: - type: textarea id: description attributes: label: Description description: Please provide details about the desired feature. validations: required: true ================================================ FILE: .github/workflows/dry-build.yml ================================================ name: Dry Build on: pull_request: branches: - main jobs: build_windows: name: Build for Windows runs-on: macos-latest strategy: fail-fast: false matrix: arch: [arm64, amd64] steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go build -o ipatool-$GOOS-$GOARCH.exe env: GOOS: windows GOARCH: ${{ matrix.arch }} build_linux: name: Build for Linux runs-on: macos-latest strategy: fail-fast: false matrix: arch: [arm64, amd64] steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go build -o ipatool-$GOOS-$GOARCH env: GOOS: linux GOARCH: ${{ matrix.arch }} build_macos: name: Build for macOS runs-on: macos-latest strategy: fail-fast: false matrix: arch: [arm64, amd64] steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go build -o ipatool-$GOOS-$GOARCH env: GOOS: darwin GOARCH: ${{ matrix.arch }} CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 ================================================ FILE: .github/workflows/integration-tests.yml ================================================ name: Integration Tests on: pull_request: branches: - main jobs: build: name: Build runs-on: macos-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go build -o ipatool env: CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 - uses: actions/upload-artifact@v4 with: name: ipatool path: ipatool if-no-files-found: error test: name: Test runs-on: macos-latest needs: [build] strategy: fail-fast: false matrix: command: [auth, download, purchase, search] steps: - uses: actions/download-artifact@v4 with: name: ipatool path: build - run: chmod +x ./build/ipatool - run: ./build/ipatool ${{ matrix.command }} --help ================================================ FILE: .github/workflows/lint.yml ================================================ name: Lint on: pull_request: branches: - main jobs: lint: name: Lint runs-on: macos-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go generate github.com/majd/ipatool/... - uses: golangci/golangci-lint-action@v8 with: version: v2.1 ================================================ FILE: .github/workflows/release.yml ================================================ name: Release on: push: tags: - "v*" jobs: get_version: name: Get version runs-on: ubuntu-latest steps: - id: set_output run: echo ::set-output name=version::${GITHUB_REF#refs/tags/v} outputs: version: ${{ steps.set_output.outputs.version }} test: name: Run tests runs-on: macos-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go generate github.com/majd/ipatool/... - run: go test -v github.com/majd/ipatool/... build: name: Build runs-on: macos-latest needs: [get_version, test] steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-arm64.exe env: GOOS: windows GOARCH: arm64 VERSION: ${{ needs.get_version.outputs.version }} - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-windows-amd64.exe env: GOOS: windows GOARCH: amd64 VERSION: ${{ needs.get_version.outputs.version }} - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-arm64 env: GOOS: linux GOARCH: arm64 VERSION: ${{ needs.get_version.outputs.version }} - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-linux-amd64 env: GOOS: linux GOARCH: amd64 VERSION: ${{ needs.get_version.outputs.version }} - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-arm64 env: GOOS: darwin GOARCH: arm64 VERSION: ${{ needs.get_version.outputs.version }} CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 CGO_ENABLED: 1 - run: go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o ipatool-$VERSION-macos-amd64 env: GOOS: darwin GOARCH: amd64 VERSION: ${{ needs.get_version.outputs.version }} CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 CGO_ENABLED: 1 - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe path: ipatool-${{ needs.get_version.outputs.version }}-windows-arm64.exe if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe path: ipatool-${{ needs.get_version.outputs.version }}-windows-amd64.exe if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64 path: ipatool-${{ needs.get_version.outputs.version }}-linux-arm64 if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64 path: ipatool-${{ needs.get_version.outputs.version }}-linux-amd64 if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 path: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 if-no-files-found: error - uses: actions/upload-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 path: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 if-no-files-found: error release_windows: name: Release for Windows runs-on: ubuntu-latest needs: [get_version, build] strategy: fail-fast: false matrix: arch: [arm64, amd64] steps: - uses: actions/checkout@v2 - uses: actions/download-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.exe path: bin - run: tar -czvf $FILE.tar.gz bin/$FILE.exe env: FILE: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }} - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum env: TARBALL: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.tar.gz - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ipatool-${{ needs.get_version.outputs.version }}-windows-${{ matrix.arch }}.* tag: ${{ github.ref }} overwrite: false file_glob: true release_linux: name: Release for Linux runs-on: ubuntu-latest needs: [get_version, build, release_windows] strategy: fail-fast: false matrix: arch: [arm64, amd64] steps: - uses: actions/checkout@v2 - uses: actions/download-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} path: bin - run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE env: FILE: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} - run: ./tools/sha256sum.sh $TARBALL > $TARBALL.sha256sum env: TARBALL: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.tar.gz - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }}.* tag: ${{ github.ref }} overwrite: false file_glob: true release_macos: name: Release for macOS runs-on: ubuntu-latest needs: [get_version, build, release_windows, release_linux] steps: - uses: actions/checkout@v2 with: path: ./ipatool - uses: actions/download-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 path: bin - run: chmod +x bin/$BIN && tar -czvf $BIN.tar.gz bin/$BIN && rm -rf bin/ env: BIN: ipatool-${{ needs.get_version.outputs.version }}-macos-arm64 - uses: actions/download-artifact@v4 with: name: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 path: bin - run: chmod +x bin/$FILE && tar -czvf $FILE.tar.gz bin/$FILE && rm -rf bin/ env: FILE: ipatool-${{ needs.get_version.outputs.version }}-macos-amd64 - id: sha256 run: | SHA256_ARM64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz) SHA256_AMD64=$(./ipatool/tools/sha256sum.sh ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz) echo $SHA256_ARM64 > ipatool-${{ needs.get_version.outputs.version }}-macos-arm64.tar.gz.sha256sum echo $SHA256_AMD64 > ipatool-${{ needs.get_version.outputs.version }}-macos-amd64.tar.gz.sha256sum echo ::set-output name=sha256_arm64::$SHA256_ARM64 echo ::set-output name=sha256_amd64::$SHA256_AMD64 - uses: svenstaro/upload-release-action@v2 with: repo_token: ${{ secrets.GITHUB_TOKEN }} file: ipatool-${{ needs.get_version.outputs.version }}-macos-* tag: ${{ github.ref }} overwrite: false file_glob: true - uses: actions/checkout@v2 with: repository: ${{ secrets.HOMEBREW_REPO }} ref: main token: ${{ secrets.GH_TOKEN }} path: homebrew-repo - run: | cd homebrew-repo sed -i "3s/.*/ sha256 \"$SHA256_ARM64\"/" Casks/ipatool.rb 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 sed -i "5s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-arm64\", target: \"ipatool\"/" Casks/ipatool.rb sed -i "7s/.*/ sha256 \"$SHA256_AMD64\"/" Casks/ipatool.rb 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 sed -i "9s/.*/ binary \"bin\/ipatool-${{ needs.get_version.outputs.version }}-macos-amd64\", target: \"ipatool\"/" Casks/ipatool.rb sed -i "12s/.*/ version \"${{ needs.get_version.outputs.version }}\"/" Casks/ipatool.rb git config --local user.name ${{ secrets.GH_NAME }} git config --local user.email ${{ secrets.GH_EMAIL }} git add Casks/ipatool.rb git commit -m "Update ipatool to v${{ needs.get_version.outputs.version }}" git push "https://${{ secrets.GH_TOKEN }}@github.com/${{ secrets.HOMEBREW_REPO }}.git" --set-upstream "main" env: SHA256_ARM64: ${{ steps.sha256.outputs.sha256_arm64 }} SHA256_AMD64: ${{ steps.sha256.outputs.sha256_amd64 }} ================================================ FILE: .github/workflows/unit-tests.yml ================================================ name: Unit Tests on: pull_request: branches: - main jobs: run_tests: name: Run tests runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: actions/setup-go@v3 with: go-version: "1.23.0" cache: true - run: go generate github.com/majd/ipatool/... - run: go test -v github.com/majd/ipatool/... ================================================ FILE: .gitignore ================================================ .DS_Store .AppleDouble .LSOverride .vscode/ .idea/ **/*_mock.go exp/ ================================================ FILE: .golangci.yml ================================================ version: "2" linters: enable: - ginkgolinter - godot - godox - importas - nlreturn - nonamedreturns - prealloc - predeclared - unconvert - unparam - usestdlibvars - wastedassign - wrapcheck - wsl exclusions: generated: lax presets: - comments - common-false-positives - legacy - std-error-handling paths: - third_party$ - builtin$ - examples$ formatters: enable: - gofmt - goimports exclusions: generated: lax paths: - third_party$ - builtin$ - examples$ ================================================ FILE: AGENTS.md ================================================ # AGENTS.md This file provides guidance for coding agents working in this repository. ## Repository overview - Project: `ipatool` - Language: Go - Entry point: `main.go` - CLI command implementations: `cmd/` ## Development workflow 1. Keep changes focused and minimal. 2. Prefer idiomatic Go and keep command behavior consistent with existing commands in `cmd/`. 3. Run formatting and tests before finalizing changes. ## Local checks Use these commands from the repository root: ```bash go generate ./... go test ./... go build ./... ``` ## Coding conventions - Follow standard Go formatting (`gofmt`). - Avoid introducing new dependencies unless necessary. - Keep user-facing text consistent with existing CLI help/output tone. - Preserve backward compatibility for CLI flags and output formats unless explicitly asked to change them. ## Commit/PR guidance - Write clear, scoped commit messages. - Summarize what changed and why in PR descriptions. - Include test/build results in your handoff. ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2021 Majd Alfhaily Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================ # IPATool [![Release](https://img.shields.io/github/release/majd/ipatool.svg?label=Release)](https://GitHub.com/majd/ipatool/releases/) [![License](https://img.shields.io/badge/License-MIT-yellow.svg)](https://github.com/majd/ipatool/blob/main/LICENSE) `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. ![Demo](./resources/demo.gif) - [Requirements](#requirements) - [Installation](#installation) - [Manual](#manual) - [Package Manager (macOS)](#package-manager-macos) - [Usage](#usage) - [Compiling](#compiling) - [License](#license) - [Releases](https://github.com/majd/ipatool/releases) - [FAQ](https://github.com/majd/ipatool/wiki/FAQ) ## Requirements - Supported operating system (Windows, Linux or macOS). - Apple ID set up to use the App Store. ## Installation ### Manual You can grab the latest version of `ipatool` from [GitHub releases](https://github.com/majd/ipatool/releases). ### Package Manager (macOS) You can install `ipatool` using [Homebrew](https://brew.sh). ```shell $ brew install ipatool ``` ## Usage To authenticate with the App Store, use the `auth` command. ``` Authenticate with the App Store Usage: ipatool auth [command] Available Commands: info Show current account info login Login to the App Store revoke Revoke your App Store credentials Flags: -h, --help help for auth Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --non-interactive run in non-interactive session --verbose enables verbose logs Use "ipatool auth [command] --help" for more information about a command. ``` To search for apps on the App Store, use the `search` command. ``` Search for iOS apps available on the App Store Usage: ipatool search [flags] Flags: -h, --help help for search -l, --limit int maximum amount of search results to retrieve (default 5) Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --non-interactive run in non-interactive session --verbose enables verbose logs ``` To obtain a license for an app, use the `purchase` command. ``` Obtain a license for the app from the App Store Usage: ipatool purchase [flags] Flags: -b, --bundle-identifier string Bundle identifier of the target iOS app (required) -h, --help help for purchase Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --non-interactive run in non-interactive session --verbose enables verbose logs ``` To obtain a list of availble app versions to download, use the `list-versions` command. ``` List the available versions of an iOS app Usage: ipatool list-versions [flags] Flags: -i, --app-id int ID of the target iOS app (required) -b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID) -h, --help help for list-versions Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --keychain-passphrase string passphrase for unlocking keychain --non-interactive run in non-interactive session --verbose enables verbose logs ``` To download a copy of the ipa file, use the `download` command. ``` Download (encrypted) iOS app packages from the App Store Usage: ipatool download [flags] Flags: -i, --app-id int ID of the target iOS app (required) -b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID) --external-version-id string External version identifier of the target iOS app (defaults to latest version when not specified) -h, --help help for download -o, --output string The destination path of the downloaded app package --purchase Obtain a license for the app if needed Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --keychain-passphrase string passphrase for unlocking keychain --non-interactive run in non-interactive session --verbose enables verbose logs ``` To resolve an external version identifier, returned by the `list-versions` command, use the `get-version-metadata` command. ``` Retrieves the metadata for a specific version of an app Usage: ipatool get-version-metadata [flags] Flags: -i, --app-id int ID of the target iOS app (required) -b, --bundle-identifier string The bundle identifier of the target iOS app (overrides the app ID) --external-version-id string External version identifier of the target iOS app (required) -h, --help help for get-version-metadata Global Flags: --format format sets output format for command; can be 'text', 'json' (default text) --keychain-passphrase string passphrase for unlocking keychain --non-interactive run in non-interactive session --verbose enables verbose logs ``` **Note:** the tool runs in interactive mode by default. Use the `--non-interactive` flag if running in an automated environment. ## Compiling The tool can be compiled using the Go toolchain. ```shell $ go build -o ipatool ``` Unit tests can be executed with the following commands. ```shell $ go generate github.com/majd/ipatool/... $ go test -v github.com/majd/ipatool/... ``` ## License IPATool is released under the [MIT license](https://github.com/majd/ipatool/blob/main/LICENSE). ================================================ FILE: cmd/auth.go ================================================ package cmd import ( "bufio" "errors" "fmt" "os" "strings" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/majd/ipatool/v2/pkg/util" "github.com/spf13/cobra" "golang.org/x/term" ) func authCmd() *cobra.Command { cmd := &cobra.Command{ Use: "auth", Short: "Authenticate with the App Store", } cmd.AddCommand(loginCmd()) cmd.AddCommand(infoCmd()) cmd.AddCommand(revokeCmd()) return cmd } func loginCmd() *cobra.Command { promptForAuthCode := func() (string, error) { authCode, err := bufio.NewReader(os.Stdin).ReadString('\n') if err != nil { return "", fmt.Errorf("failed to read string: %w", err) } authCode = strings.Trim(authCode, "\n") authCode = strings.Trim(authCode, "\r") return authCode, nil } var email, password, authCode string cmd := &cobra.Command{ Use: "login", Short: "Login to the App Store", RunE: func(cmd *cobra.Command, args []string) error { interactive := cmd.Context().Value("interactive").(bool) if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") } if password == "" && interactive { dependencies.Logger.Log().Msg("enter password:") bytes, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return fmt.Errorf("failed to read password: %w", err) } password = string(bytes) } var lastErr error // nolint:wrapcheck return retry.Do(func() error { if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive { dependencies.Logger.Log().Msg("enter 2FA code:") var err error authCode, err = promptForAuthCode() if err != nil { return fmt.Errorf("failed to read auth code: %w", err) } } dependencies.Logger.Verbose(). Str("password", password). Str("email", email). Str("authCode", util.IfEmpty(authCode, "")). Msg("logging in") bag, err := dependencies.AppStore.Bag(appstore.BagInput{}) if err != nil { return fmt.Errorf("failed to get bag: %w", err) } output, err := dependencies.AppStore.Login(appstore.LoginInput{ Email: email, Password: password, AuthCode: authCode, Endpoint: bag.AuthEndpoint, }) if err != nil { if errors.Is(err, appstore.ErrAuthCodeRequired) && !interactive { dependencies.Logger.Log().Msg("2FA code is required; run the command again and supply a code using the `--auth-code` flag") return nil } return err } dependencies.Logger.Log(). Str("name", output.Account.Name). Str("email", output.Account.Email). Bool("success", true). Send() return nil }, retry.LastErrorOnly(true), retry.DelayType(retry.FixedDelay), retry.Delay(time.Millisecond), retry.Attempts(2), retry.RetryIf(func(err error) bool { lastErr = err return errors.Is(err, appstore.ErrAuthCodeRequired) }), ) }, } cmd.Flags().StringVarP(&email, "email", "e", "", "email address for the Apple ID (required)") cmd.Flags().StringVarP(&password, "password", "p", "", "password for the Apple ID (required)") cmd.Flags().StringVar(&authCode, "auth-code", "", "2FA code for the Apple ID") _ = cmd.MarkFlagRequired("email") return cmd } // nolint:wrapcheck func infoCmd() *cobra.Command { return &cobra.Command{ Use: "info", Short: "Show current account info", RunE: func(cmd *cobra.Command, args []string) error { output, err := dependencies.AppStore.AccountInfo() if err != nil { return err } dependencies.Logger.Log(). Str("name", output.Account.Name). Str("email", output.Account.Email). Bool("success", true). Send() return nil }, } } // nolint:wrapcheck func revokeCmd() *cobra.Command { return &cobra.Command{ Use: "revoke", Short: "Revoke your App Store credentials", RunE: func(cmd *cobra.Command, args []string) error { err := dependencies.AppStore.Revoke() if err != nil { return err } dependencies.Logger.Log().Bool("success", true).Send() return nil }, } } ================================================ FILE: cmd/common.go ================================================ package cmd import ( "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/99designs/keyring" cookiejar "github.com/juju/persistent-cookiejar" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/log" "github.com/majd/ipatool/v2/pkg/util" "github.com/majd/ipatool/v2/pkg/util/machine" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" "github.com/rs/zerolog" "github.com/spf13/cobra" "golang.org/x/term" ) var dependencies = Dependencies{} var keychainPassphrase string type Dependencies struct { Logger log.Logger OS operatingsystem.OperatingSystem Machine machine.Machine CookieJar http.CookieJar Keychain keychain.Keychain AppStore appstore.AppStore } // newLogger returns a new logger instance. func newLogger(format OutputFormat, verbose bool) log.Logger { var writer io.Writer switch format { case OutputFormatJSON: writer = zerolog.SyncWriter(os.Stdout) case OutputFormatText: writer = log.NewWriter() } return log.NewLogger(log.Args{ Verbose: verbose, Writer: writer, }, ) } // newCookieJar returns a new cookie jar instance. func newCookieJar(machine machine.Machine) http.CookieJar { return util.Must(cookiejar.New(&cookiejar.Options{ Filename: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName), })) } // newKeychain returns a new keychain instance. func newKeychain(machine machine.Machine, logger log.Logger, interactive bool) keychain.Keychain { ring := util.Must(keyring.Open(keyring.Config{ AllowedBackends: []keyring.BackendType{ keyring.KeychainBackend, keyring.SecretServiceBackend, keyring.FileBackend, }, ServiceName: KeychainServiceName, FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName), FilePasswordFunc: func(s string) (string, error) { if keychainPassphrase == "" && !interactive { return "", errors.New("keychain passphrase is required when not running in interactive mode; use the \"--keychain-passphrase\" flag") } if keychainPassphrase != "" { return keychainPassphrase, nil } path := strings.Split(s, " unlock ")[1] logger.Log().Msgf("enter passphrase to unlock %s (this is separate from your Apple ID password): ", path) bytes, err := term.ReadPassword(int(os.Stdin.Fd())) if err != nil { return "", fmt.Errorf("failed to read password: %w", err) } password := string(bytes) password = strings.Trim(password, "\n") password = strings.Trim(password, "\r") return password, nil }, })) return keychain.New(keychain.Args{Keyring: ring}) } // initWithCommand initializes the dependencies of the command. func initWithCommand(cmd *cobra.Command) { verbose := cmd.Flag("verbose").Value.String() == "true" interactive, _ := cmd.Context().Value("interactive").(bool) format := util.Must(OutputFormatFromString(cmd.Flag("format").Value.String())) dependencies.Logger = newLogger(format, verbose) dependencies.OS = operatingsystem.New() dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) dependencies.CookieJar = newCookieJar(dependencies.Machine) dependencies.Keychain = newKeychain(dependencies.Machine, dependencies.Logger, interactive) dependencies.AppStore = appstore.NewAppStore(appstore.Args{ CookieJar: dependencies.CookieJar, OperatingSystem: dependencies.OS, Keychain: dependencies.Keychain, Machine: dependencies.Machine, }) util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine)) } // createConfigDirectory creates the configuration directory for the CLI tool, if needed. func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error { configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName) _, err := os.Stat(configDirectoryPath) if err != nil && os.IsNotExist(err) { err = os.MkdirAll(configDirectoryPath, 0700) if err != nil { return fmt.Errorf("failed to create config directory: %w", err) } } else if err != nil { return fmt.Errorf("could not read metadata: %w", err) } return nil } ================================================ FILE: cmd/constants.go ================================================ package cmd const ( ConfigDirectoryName = ".ipatool" CookieJarFileName = "cookies" KeychainServiceName = "ipatool-auth.service" ) ================================================ FILE: cmd/download.go ================================================ package cmd import ( "errors" "os" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/schollz/progressbar/v3" "github.com/spf13/cobra" ) // nolint:wrapcheck func downloadCmd() *cobra.Command { var ( acquireLicense bool outputPath string appID int64 bundleID string externalVersionID string ) cmd := &cobra.Command{ Use: "download", Short: "Download (encrypted) iOS app packages from the App Store", RunE: func(cmd *cobra.Command, args []string) error { if appID == 0 && bundleID == "" { return errors.New("either the app ID or the bundle identifier must be specified") } var lastErr error var acc appstore.Account purchased := false return retry.Do(func() error { infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err } acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) if err != nil { return err } acc = loginResult.Account } app := appstore.App{ID: appID} if bundleID != "" { lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) if err != nil { return err } app = lookupResult.App } if errors.Is(lastErr, appstore.ErrLicenseRequired) { err := dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: app}) if err != nil { return err } purchased = true dependencies.Logger.Verbose(). Bool("success", true). Msg("purchase") } interactive, _ := cmd.Context().Value("interactive").(bool) var progress *progressbar.ProgressBar if interactive { progress = progressbar.NewOptions64(1, progressbar.OptionSetDescription("downloading"), progressbar.OptionSetWriter(os.Stdout), progressbar.OptionShowBytes(true), progressbar.OptionSetWidth(20), progressbar.OptionFullWidth(), progressbar.OptionThrottle(65*time.Millisecond), progressbar.OptionShowCount(), progressbar.OptionClearOnFinish(), progressbar.OptionSpinnerType(14), progressbar.OptionSetRenderBlankState(true), progressbar.OptionSetElapsedTime(false), progressbar.OptionSetPredictTime(false), ) } out, err := dependencies.AppStore.Download(appstore.DownloadInput{ Account: acc, App: app, OutputPath: outputPath, Progress: progress, ExternalVersionID: externalVersionID}) if err != nil { return err } err = dependencies.AppStore.ReplicateSinf(appstore.ReplicateSinfInput{Sinfs: out.Sinfs, PackagePath: out.DestinationPath}) if err != nil { return err } dependencies.Logger.Log(). Str("output", out.DestinationPath). Bool("purchased", purchased). Bool("success", true). Send() return nil }, retry.LastErrorOnly(true), retry.DelayType(retry.FixedDelay), retry.Delay(time.Millisecond), retry.Attempts(2), retry.RetryIf(func(err error) bool { lastErr = err if errors.Is(err, appstore.ErrPasswordTokenExpired) { return true } if errors.Is(err, appstore.ErrLicenseRequired) && acquireLicense { return true } return false }), ) }, } cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") cmd.Flags().StringVarP(&outputPath, "output", "o", "", "The destination path of the downloaded app package") cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (defaults to latest version when not specified)") cmd.Flags().BoolVar(&acquireLicense, "purchase", false, "Obtain a license for the app if needed") return cmd } ================================================ FILE: cmd/get_version_metadata.go ================================================ package cmd import ( "errors" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" ) // nolint:wrapcheck func getVersionMetadataCmd() *cobra.Command { var ( appID int64 bundleID string externalVersionID string ) cmd := &cobra.Command{ Use: "get-version-metadata", Short: "Retrieves the metadata for a specific version of an app", RunE: func(cmd *cobra.Command, args []string) error { if appID == 0 && bundleID == "" { return errors.New("either the app ID or the bundle identifier must be specified") } var lastErr error var acc appstore.Account return retry.Do(func() error { infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err } acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) if err != nil { return err } acc = loginResult.Account } app := appstore.App{ID: appID} if bundleID != "" { lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) if err != nil { return err } app = lookupResult.App } out, err := dependencies.AppStore.GetVersionMetadata(appstore.GetVersionMetadataInput{ Account: acc, App: app, VersionID: externalVersionID, }) if err != nil { return err } dependencies.Logger.Log(). Str("externalVersionID", externalVersionID). Str("displayVersion", out.DisplayVersion). Time("releaseDate", out.ReleaseDate). Bool("success", true). Send() return nil }, retry.LastErrorOnly(true), retry.DelayType(retry.FixedDelay), retry.Delay(time.Millisecond), retry.Attempts(2), retry.RetryIf(func(err error) bool { lastErr = err return errors.Is(err, appstore.ErrPasswordTokenExpired) }), ) }, } cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (required)") _ = cmd.MarkFlagRequired("external-version-id") return cmd } ================================================ FILE: cmd/list_versions.go ================================================ package cmd import ( "errors" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" ) // nolint:wrapcheck func ListVersionsCmd() *cobra.Command { var ( appID int64 bundleID string ) cmd := &cobra.Command{ Use: "list-versions", Short: "List the available versions of an iOS app", RunE: func(cmd *cobra.Command, args []string) error { if appID == 0 && bundleID == "" { return errors.New("either the app ID or the bundle identifier must be specified") } var lastErr error var acc appstore.Account return retry.Do(func() error { infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err } acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) if err != nil { return err } acc = loginResult.Account } app := appstore.App{ID: appID} if bundleID != "" { lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) if err != nil { return err } app = lookupResult.App } out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app}) if err != nil { return err } dependencies.Logger.Log(). Interface("externalVersionIdentifiers", out.ExternalVersionIdentifiers). Str("bundleID", app.BundleID). Bool("success", true). Send() return nil }, retry.LastErrorOnly(true), retry.DelayType(retry.FixedDelay), retry.Delay(time.Millisecond), retry.Attempts(2), retry.RetryIf(func(err error) bool { lastErr = err return errors.Is(err, appstore.ErrPasswordTokenExpired) }), ) }, } cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") return cmd } ================================================ FILE: cmd/output_format.go ================================================ package cmd import ( "fmt" "github.com/thediveo/enumflag/v2" ) type OutputFormat enumflag.Flag const ( OutputFormatText OutputFormat = iota OutputFormatJSON ) func OutputFormatFromString(value string) (OutputFormat, error) { switch value { case "json": return OutputFormatJSON, nil case "text": return OutputFormatText, nil default: return OutputFormatJSON, fmt.Errorf("invalid output format '%s'", value) } } ================================================ FILE: cmd/purchase.go ================================================ package cmd import ( "errors" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" ) // nolint:wrapcheck func purchaseCmd() *cobra.Command { var bundleID string cmd := &cobra.Command{ Use: "purchase", Short: "Obtain a license for the app from the App Store", RunE: func(cmd *cobra.Command, args []string) error { var lastErr error var acc appstore.Account return retry.Do(func() error { infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err } acc = infoResult.Account if errors.Is(lastErr, appstore.ErrPasswordTokenExpired) { loginResult, err := dependencies.AppStore.Login(appstore.LoginInput{Email: acc.Email, Password: acc.Password}) if err != nil { return err } acc = loginResult.Account } lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) if err != nil { return err } err = dependencies.AppStore.Purchase(appstore.PurchaseInput{Account: acc, App: lookupResult.App}) if err != nil { return err } dependencies.Logger.Log().Bool("success", true).Send() return nil }, retry.LastErrorOnly(true), retry.DelayType(retry.FixedDelay), retry.Delay(time.Millisecond), retry.Attempts(2), retry.RetryIf(func(err error) bool { lastErr = err return errors.Is(err, appstore.ErrPasswordTokenExpired) }), ) }, } cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "Bundle identifier of the target iOS app (required)") _ = cmd.MarkFlagRequired("bundle-identifier") return cmd } ================================================ FILE: cmd/root.go ================================================ package cmd import ( "errors" "reflect" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" "github.com/thediveo/enumflag/v2" "golang.org/x/net/context" ) var version = "dev" func rootCmd() *cobra.Command { var ( verbose bool nonInteractive bool format OutputFormat ) cmd := &cobra.Command{ Use: "ipatool", Short: "A cli tool for interacting with Apple's ipa files", SilenceErrors: true, SilenceUsage: true, Version: version, PersistentPreRun: func(cmd *cobra.Command, args []string) { ctx := context.WithValue(context.Background(), "interactive", !nonInteractive) cmd.SetContext(ctx) initWithCommand(cmd) }, } cmd.PersistentFlags().VarP( enumflag.New(&format, "format", map[OutputFormat][]string{ OutputFormatText: {"text"}, OutputFormatJSON: {"json"}, }, enumflag.EnumCaseSensitive), "format", "", "sets output format for command; can be 'text', 'json'") cmd.PersistentFlags().BoolVar(&verbose, "verbose", false, "enables verbose logs") cmd.PersistentFlags().BoolVarP(&nonInteractive, "non-interactive", "", false, "run in non-interactive session") cmd.PersistentFlags().StringVar(&keychainPassphrase, "keychain-passphrase", "", "passphrase for unlocking keychain") cmd.AddCommand(authCmd()) cmd.AddCommand(downloadCmd()) cmd.AddCommand(purchaseCmd()) cmd.AddCommand(searchCmd()) cmd.AddCommand(ListVersionsCmd()) cmd.AddCommand(getVersionMetadataCmd()) return cmd } // Execute runs the program and returns the appropriate exit status code. func Execute() int { cmd := rootCmd() err := cmd.Execute() if err != nil { if reflect.ValueOf(dependencies).IsZero() { initWithCommand(cmd) } var appstoreErr *appstore.Error if errors.As(err, &appstoreErr) { dependencies.Logger.Verbose().Stack(). Err(err). Interface("metadata", appstoreErr.Metadata). Send() } else { dependencies.Logger.Verbose().Stack().Err(err).Send() } dependencies.Logger.Error(). Err(err). Bool("success", false). Send() return 1 } return 0 } ================================================ FILE: cmd/search.go ================================================ package cmd import ( "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" ) // nolint:wrapcheck func searchCmd() *cobra.Command { var limit int64 cmd := &cobra.Command{ Use: "search ", Short: "Search for iOS apps available on the App Store", Args: cobra.ExactArgs(1), RunE: func(cmd *cobra.Command, args []string) error { infoResult, err := dependencies.AppStore.AccountInfo() if err != nil { return err } output, err := dependencies.AppStore.Search(appstore.SearchInput{ Account: infoResult.Account, Term: args[0], Limit: limit, }) if err != nil { return err } dependencies.Logger.Log(). Int("count", output.Count). Array("apps", appstore.Apps(output.Results)). Send() return nil }, } cmd.Flags().Int64VarP(&limit, "limit", "l", 5, "maximum amount of search results to retrieve") return cmd } ================================================ FILE: go.mod ================================================ module github.com/majd/ipatool/v2 go 1.23.0 toolchain go1.23.2 require ( github.com/99designs/keyring v1.2.1 github.com/avast/retry-go v3.0.0+incompatible github.com/juju/persistent-cookiejar v1.0.0 github.com/onsi/ginkgo/v2 v2.5.0 github.com/onsi/gomega v1.24.0 github.com/rs/zerolog v1.28.0 github.com/schollz/progressbar/v3 v3.13.1 github.com/spf13/cobra v1.6.1 github.com/thediveo/enumflag/v2 v2.0.1 go.uber.org/mock v0.4.0 golang.org/x/net v0.38.0 golang.org/x/term v0.30.0 howett.net/plist v1.0.0 ) require ( github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect github.com/danieljoos/wincred v1.1.2 // indirect github.com/dvsekhvalnov/jose2go v1.7.0 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 // indirect github.com/google/go-cmp v0.6.0 // indirect github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c // indirect github.com/inconshreveable/mousetrap v1.0.1 // indirect github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.17 // indirect github.com/mattn/go-runewidth v0.0.14 // indirect github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db // indirect github.com/mtibben/percent v0.2.1 // indirect github.com/pkg/errors v0.9.1 // indirect github.com/rivo/uniseg v0.2.0 // indirect github.com/spf13/pflag v1.0.5 // indirect golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e // indirect golang.org/x/mod v0.17.0 // indirect golang.org/x/sys v0.31.0 // indirect golang.org/x/text v0.23.0 // indirect golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d // indirect gopkg.in/errgo.v1 v1.0.1 // indirect gopkg.in/retry.v1 v1.0.3 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) ================================================ FILE: go.sum ================================================ github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4 h1:/vQbFIOMbk2FiG/kXiLl8BRyzTWDw7gX/Hz7Dd5eDMs= github.com/99designs/go-keychain v0.0.0-20191008050251-8e49817e8af4/go.mod h1:hN7oaIRCjzsZ2dE+yG5k+rsdt3qcwykqK6HVGcKwsw4= github.com/99designs/keyring v1.2.1 h1:tYLp1ULvO7i3fI5vE21ReQuj99QFSs7lGm0xWyJo87o= github.com/99designs/keyring v1.2.1/go.mod h1:fc+wB5KTk9wQ9sDx0kFXB3A0MaeGHM9AwRStKOQ5vOA= github.com/avast/retry-go v3.0.0+incompatible h1:4SOWQ7Qs+oroOTQOYnAHqelpCO0biHSxpiH9JdtuBj0= github.com/avast/retry-go v3.0.0+incompatible/go.mod h1:XtSnn+n/sHqQIpZ10K1qAevBhOOCWBLXXy3hyiqqBrY= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= github.com/danieljoos/wincred v1.1.2 h1:QLdCxFs1/Yl4zduvBdcHB8goaYk9RARS2SgLLRuAyr0= github.com/danieljoos/wincred v1.1.2/go.mod h1:GijpziifJoIBfYh+S7BbkdUTU4LfM+QnGqR5Vl2tAx0= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/dvsekhvalnov/jose2go v1.7.0 h1:bnQc8+GMnidJZA8zc6lLEAb4xNrIqHwO+9TzqvtQZPo= github.com/dvsekhvalnov/jose2go v1.7.0/go.mod h1:QsHjhyTlD/lAVqn/NSbVZmSCGeDehTB/mPZadG+mhXU= github.com/frankban/quicktest v1.2.2 h1:xfmOhhoH5fGPgbEAlhLpJH9p0z/0Qizio9osmvn9IUY= github.com/frankban/quicktest v1.2.2/go.mod h1:Qh/WofXFeiAFII1aEBu529AtJo6Zg2VHscnEsbBnJ20= github.com/go-logr/logr v1.2.3 h1:2DntVwHkVopvECVRSlL5PSo9eG+cAkDCuckLubN+rq0= github.com/go-logr/logr v1.2.3/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2 h1:ZpnhV/YsD2/4cESfV5+Hoeu/iUR3ruzNvZ+yQfO03a0= github.com/godbus/dbus v0.0.0-20190726142602-4481cbc300e2/go.mod h1:bBOAhwG1umN6/6ZUMtDFBMQR8jRg9O75tm9K00oMsK4= github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA= github.com/golang/protobuf v1.5.2 h1:ROPKBNFfQgOUMifHyP+KYbvpjbdoFNs+aK7DXlji0Tw= github.com/golang/protobuf v1.5.2/go.mod h1:XVQd3VNwM+JqD3oG2Ue2ip4fOMUkwXdXDdiuN0vRsmY= github.com/google/go-cmp v0.2.1-0.20190312032427-6f77996f0c42/go.mod h1:8QqcDgzrUqlUb/G2PQTWiueGozuR1884gddMywk6iLU= github.com/google/go-cmp v0.6.0 h1:ofyhxvXcZhMsU5ulbFiLKl/XBFqE1GSq7atu8tAmTRI= github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c h1:6rhixN/i8ZofjG1Y75iExal34USq5p+wiN1tpie8IrU= github.com/gsterjov/go-libsecret v0.0.0-20161001094733-a6f4afe4910c/go.mod h1:NMPJylDgVpX0MLRlPy15sqSwOFv/U1GZ2m21JhFfek0= github.com/inconshreveable/mousetrap v1.0.1 h1:U3uMjPSQEBMNp1lFxmllqCPM6P5u/Xq7Pgzkat/bFNc= github.com/inconshreveable/mousetrap v1.0.1/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a h1:45JtCyuNYE+QN9aPuR1ID9++BQU+NMTMudHSuaK0Las= github.com/juju/go4 v0.0.0-20160222163258-40d72ab9641a/go.mod h1:RVHtZuvrpETIepiNUrNlih2OynoFf1eM6DGC6dloXzk= github.com/juju/persistent-cookiejar v1.0.0 h1:Ag7+QLzqC2m+OYXy2QQnRjb3gTkEBSZagZ6QozwT3EQ= github.com/juju/persistent-cookiejar v1.0.0/go.mod h1:zrbmo4nBKaiP/Ez3F67ewkMbzGYfXyMvRtbOfuAwG0w= github.com/k0kubun/go-ansi v0.0.0-20180517002512-3bf9e2903213/go.mod h1:vNUNkEQ1e29fT/6vq2aBdFsgNPmy8qMdSay1npru+Sw= github.com/kr/pretty v0.1.0 h1:L/CwN0zerZDmRFUapSPitk6f+Q3+0za1rQkzVuMiMFI= github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= github.com/kr/text v0.1.0 h1:45sCR5RtlFHMR4UwH9sdQ5TC8v0qDQCHnXt+kaKSTVE= github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= github.com/mattn/go-colorable v0.1.12/go.mod h1:u5H1YNBxpqRaxsYJYSkiCWKzEfiAb1Gb520KVy5xxl4= github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= github.com/mattn/go-isatty v0.0.14/go.mod h1:7GGIvUiUoEMVVmxf/4nioHXj79iQHKdU27kJ6hsGG94= github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-isatty v0.0.17 h1:BTarxUcIeDqL27Mc+vyvdWYSL28zpIhv3RoTdsLMPng= github.com/mattn/go-isatty v0.0.17/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= github.com/mattn/go-runewidth v0.0.14 h1:+xnbZSEeDbOIg5/mE6JF0w6n9duR1l3/WmbinWVwUuU= github.com/mattn/go-runewidth v0.0.14/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db h1:62I3jR2EmQ4l5rM/4FEfDWcRD+abF5XlKShorW5LRoQ= github.com/mitchellh/colorstring v0.0.0-20190213212951-d06e56a500db/go.mod h1:l0dey0ia/Uv7NcFFVbCLtqEBQbrT4OCwCSKTEv6enCw= github.com/mtibben/percent v0.2.1 h1:5gssi8Nqo8QU/r2pynCm+hBQHpkB/uNK7BJCFogWdzs= github.com/mtibben/percent v0.2.1/go.mod h1:KG9uO+SZkUp+VkRHsCdYQV3XSZrrSpR3O9ibNBTZrns= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/onsi/ginkgo/v2 v2.5.0 h1:TRtrvv2vdQqzkwrQ1ke6vtXf7IK34RBUJafIy1wMwls= github.com/onsi/ginkgo/v2 v2.5.0/go.mod h1:Luc4sArBICYCS8THh8v3i3i5CuSZO+RaQRaJoeNwomw= github.com/onsi/gomega v1.24.0 h1:+0glovB9Jd6z3VR+ScSwQqXVTIfJcGA9UBM8yzQxhqg= github.com/onsi/gomega v1.24.0/go.mod h1:Z/NWtiqwBrwUt4/2loMmHL63EDLnYHmVbuBpDr2vQAg= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a h1:3QH7VyOaaiUHNrA9Se4YQIRkDTCw1EJls9xTUCaCeRM= github.com/rogpeppe/clock v0.0.0-20190514195947-2896927a307a/go.mod h1:4r5QyqhjIWCcK8DO4KMclc5Iknq5qVBAlbYYzAbUScQ= github.com/rs/xid v1.4.0/go.mod h1:trrq9SKmegXys3aeAKXMUTdJsYXVwGY3RLcfgqegfbg= github.com/rs/zerolog v1.28.0 h1:MirSo27VyNi7RJYP3078AA1+Cyzd2GB66qy3aUHvsWY= github.com/rs/zerolog v1.28.0/go.mod h1:NILgTygv/Uej1ra5XxGf82ZFSLk58MFGAUS2o6usyD0= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/schollz/progressbar/v3 v3.13.1 h1:o8rySDYiQ59Mwzy2FELeHY5ZARXZTVJC7iHD6PEFUiE= github.com/schollz/progressbar/v3 v3.13.1/go.mod h1:xvrbki8kfT1fzWzBT/UZd9L6GA+jdL7HAgq2RFnO6fQ= github.com/sirupsen/logrus v1.9.0 h1:trlNQbNUG3OdDrDil03MCb1H2o9nJ1x4/5LYw7byDE0= github.com/sirupsen/logrus v1.9.0/go.mod h1:naHLuLoDiP4jHNo9R0sCBMtWGeIprob74mVsIT4qYEQ= github.com/spf13/cobra v1.6.1 h1:o94oiPyS4KD1mPy2fmcYYHHfCxLqYjJOhGsCHFZtEzA= github.com/spf13/cobra v1.6.1/go.mod h1:IOw/AERYS7UzyrGinqmz6HLUo219MORXGxhbaJUqzrY= github.com/spf13/pflag v1.0.5 h1:iy+VFUOCP1a+8yFto/drg2CJ5u0yRoB7fZw3DKv/JXA= github.com/spf13/pflag v1.0.5/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/objx v0.3.0 h1:NGXK3lHquSN08v5vWalVI/L8XU9hdzE/G6xsrze47As= github.com/stretchr/objx v0.3.0/go.mod h1:qt09Ya8vawLte6SNmTgCsAVtYtaKzEcn8ATUoHMkEqE= github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= github.com/thediveo/enumflag/v2 v2.0.1 h1:2bmWZPD2uSARDsOjXIdLRlNcYBFNF9xX0RNUNF2vKic= github.com/thediveo/enumflag/v2 v2.0.1/go.mod h1:SyxyCNvv0QeRtZ7fjuaUz4FRLC3cWuDiD7QdORU0MGg= go.uber.org/mock v0.4.0 h1:VcM4ZOtdbR4f6VXfiOpwpVJDL6lCReaZ6mw31wqh7KU= go.uber.org/mock v0.4.0/go.mod h1:a6FSlNadKUHUa9IP5Vyt1zh4fC7uAwxMutEAscFbkZc= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e h1:+WEEuIdZHnUeJJmEUjyYC2gfUMj69yZXw17EnHg/otA= golang.org/x/exp v0.0.0-20220722155223-a9213eeb770e/go.mod h1:Kr81I6Kryrl9sr8s2FK3vxD90NdsKWRuOIl2O4CvYbA= golang.org/x/mod v0.17.0 h1:zY54UmvipHiNd+pm+m0x9KhZ9hl1/7QNMyxXbc6ICqA= golang.org/x/mod v0.17.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= golang.org/x/net v0.38.0 h1:vRMAPTMaeGqVhG5QyLJHqNDwecKTomGeqbnfZyKlBI8= golang.org/x/net v0.38.0/go.mod h1:ivrbrMbzFq5J41QOQh0siUuly180yBYtLp+CKbEaFx8= golang.org/x/sync v0.12.0 h1:MHc5BpPuC30uJk597Ri8TV3CNZcTLu6B6z4lJy+g6Jw= golang.org/x/sync v0.12.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA= golang.org/x/sys v0.0.0-20210630005230-0f9fa26af87c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210819135213-f52c844e1c1c/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210927094055-39ccf1dd6fa6/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.31.0 h1:ioabZlmFYtWhL+TRYpcnNlLwhyxaM9kWTDEmfnprqik= golang.org/x/sys v0.31.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k= golang.org/x/term v0.6.0/go.mod h1:m6U89DPEgQRMq3DNkDClhWw02AUbt2daBVO4cn4Hv9U= golang.org/x/term v0.30.0 h1:PQ39fJZ+mfadBm0y5WlL4vlM7Sx1Hgf13sMIY2+QS9Y= golang.org/x/term v0.30.0/go.mod h1:NYYFdzHoI5wRh/h5tDMdMqCqPJZEuNqVR5xJLd/n67g= golang.org/x/text v0.23.0 h1:D71I7dUrlY+VX0gQShAThNGHFxZ13dGLBHQLVl1mJlY= golang.org/x/text v0.23.0/go.mod h1:/BLNzu4aZCJ1+kcD0DNRotWKage4q2rGVAg4o22unh4= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d h1:vU5i/LfpvrRCpgM/VPfJLg5KjxD3E+hfT1SH+d9zLwg= golang.org/x/tools v0.21.1-0.20240508182429-e35e4ccd0d2d/go.mod h1:aiJjzUbINMkxbQROHiO6hDPo2LHcIPhhQsa9DLh0yGk= google.golang.org/protobuf v1.28.0 h1:w43yiav+6bVFTBQFZX0r7ipe9JQ1QsbMgHwbBziscLw= google.golang.org/protobuf v1.28.0/go.mod h1:HV8QOd/L58Z+nl8r43ehVNZIU/HEI6OcFqwMG9pJV4I= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b h1:QRR6H1YWRnHb4Y/HeNFCTJLFVxaq6wH4YuVdsUOr75U= gopkg.in/check.v1 v1.0.0-20200902074654-038fdea0a05b/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/errgo.v1 v1.0.1 h1:oQFRXzZ7CkBGdm1XZm/EbQYaYNNEElNBOd09M6cqNso= gopkg.in/errgo.v1 v1.0.1/go.mod h1:3NjfXwocQRYAPTq4/fzX+CwUhPRcR/azYRhj8G+LqMo= gopkg.in/retry.v1 v1.0.3 h1:a9CArYczAVv6Qs6VGoLMio99GEs7kY9UzSF9+LD+iGs= gopkg.in/retry.v1 v1.0.3/go.mod h1:FJkXmWiMaAo7xB+xhvDF59zhfjDWyzmyAxiT4dB688g= gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= howett.net/plist v1.0.0 h1:7CrbWYbPPO/PyNy38b2EB/+gYbjCe2DXBxgtOOZbSQM= howett.net/plist v1.0.0/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= ================================================ FILE: main.go ================================================ package main import ( "os" "github.com/majd/ipatool/v2/cmd" ) func main() { os.Exit(cmd.Execute()) } ================================================ FILE: pkg/appstore/account.go ================================================ package appstore type Account struct { Email string `json:"email,omitempty"` PasswordToken string `json:"passwordToken,omitempty"` DirectoryServicesID string `json:"directoryServicesIdentifier,omitempty"` Name string `json:"name,omitempty"` StoreFront string `json:"storeFront,omitempty"` Password string `json:"password,omitempty"` Pod string `json:"pod,omitempty"` } ================================================ FILE: pkg/appstore/app.go ================================================ package appstore import ( "github.com/rs/zerolog" ) type App struct { ID int64 `json:"trackId,omitempty"` BundleID string `json:"bundleId,omitempty"` Name string `json:"trackName,omitempty"` Version string `json:"version,omitempty"` Price float64 `json:"price,omitempty"` } type VersionHistoryInfo struct { App App LatestVersion string VersionIdentifiers []string } type VersionDetails struct { VersionID string VersionString string Success bool Error string } type Apps []App func (apps Apps) MarshalZerologArray(a *zerolog.Array) { for _, app := range apps { a.Object(app) } } func (a App) MarshalZerologObject(event *zerolog.Event) { event. Int64("id", a.ID). Str("bundleID", a.BundleID). Str("name", a.Name). Str("version", a.Version). Float64("price", a.Price) } ================================================ FILE: pkg/appstore/app_test.go ================================================ package appstore import ( "bytes" "encoding/json" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/rs/zerolog" ) var _ = Describe("App", func() { It("marshals apps array", func() { apps := Apps{ { ID: 42, BundleID: "app.bundle.id", Name: "app name", Version: "1.0", Price: 0, }, { ID: 1, BundleID: "app.bundle.id2", Name: "app name2", Version: "2.0", Price: 0.99, }, } buffer := bytes.NewBuffer([]byte{}) logger := zerolog.New(buffer) event := logger.Log().Array("apps", apps) event.Send() var out map[string]interface{} err := json.Unmarshal(buffer.Bytes(), &out) Expect(err).ToNot(HaveOccurred()) Expect(out["apps"]).To(HaveLen(2)) }) It("marshalls app object", func() { app := App{ ID: 42, BundleID: "app.bundle.id", Name: "app name", Version: "1.0", Price: 0, } buffer := bytes.NewBuffer([]byte{}) logger := zerolog.New(buffer) event := logger.Log() app.MarshalZerologObject(event) event.Send() var out map[string]interface{} err := json.Unmarshal(buffer.Bytes(), &out) Expect(err).ToNot(HaveOccurred()) Expect(out["id"]).To(Equal(float64(42))) Expect(out["bundleID"]).To(Equal("app.bundle.id")) Expect(out["name"]).To(Equal("app name")) Expect(out["version"]).To(Equal("1.0")) Expect(out["price"]).To(Equal(float64(0))) }) It("formats ipa name correctly", func() { app := App{ ID: 42, BundleID: "app.bundle-id1", Name: " some app&symb.ols2 !!!", Version: "1.0", Price: 0, } Expect(fileName(app, "1.0")).To(Equal("app.bundle-id1_42_1.0.ipa")) }) }) ================================================ FILE: pkg/appstore/appstore.go ================================================ package appstore import ( "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/util/machine" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" ) type AppStore interface { // Login authenticates with the App Store. Login(input LoginInput) (LoginOutput, error) // AccountInfo returns the information of the authenticated account. AccountInfo() (AccountInfoOutput, error) // Revoke revokes the active credentials. Revoke() error // Lookup looks apps up based on the specified bundle identifier. Lookup(input LookupInput) (LookupOutput, error) // Search searches the App Store for apps matching the specified term. Search(input SearchInput) (SearchOutput, error) // Purchase acquires a license for the desired app. // Note: only free apps are supported. Purchase(input PurchaseInput) error // Download downloads the IPA package from the App Store to the desired location. Download(input DownloadInput) (DownloadOutput, error) // ReplicateSinf replicates the sinf for the IPA package. ReplicateSinf(input ReplicateSinfInput) error // VersionHistory lists the available versions of the specified app. ListVersions(input ListVersionsInput) (ListVersionsOutput, error) // GetVersionMetadata returns the metadata for the specified version. GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error) // Bag fetches the bag which contains endpoint definitions. Bag(input BagInput) (BagOutput, error) } type appstore struct { keychain keychain.Keychain loginClient http.Client[loginResult] searchClient http.Client[searchResult] purchaseClient http.Client[purchaseResult] downloadClient http.Client[downloadResult] bagClient http.Client[bagResult] httpClient http.Client[interface{}] machine machine.Machine os operatingsystem.OperatingSystem } type Args struct { Keychain keychain.Keychain CookieJar http.CookieJar OperatingSystem operatingsystem.OperatingSystem Machine machine.Machine } func NewAppStore(args Args) AppStore { clientArgs := http.Args{ CookieJar: args.CookieJar, } return &appstore{ keychain: args.Keychain, loginClient: http.NewClient[loginResult](clientArgs), searchClient: http.NewClient[searchResult](clientArgs), purchaseClient: http.NewClient[purchaseResult](clientArgs), downloadClient: http.NewClient[downloadResult](clientArgs), bagClient: http.NewClient[bagResult](clientArgs), httpClient: http.NewClient[interface{}](clientArgs), machine: args.Machine, os: args.OperatingSystem, } } ================================================ FILE: pkg/appstore/appstore_account_info.go ================================================ package appstore import ( "encoding/json" "fmt" ) type AccountInfoOutput struct { Account Account } func (t *appstore) AccountInfo() (AccountInfoOutput, error) { data, err := t.keychain.Get("account") if err != nil { return AccountInfoOutput{}, fmt.Errorf("failed to get account: %w", err) } var acc Account err = json.Unmarshal(data, &acc) if err != nil { return AccountInfoOutput{}, fmt.Errorf("failed to unmarshal json: %w", err) } return AccountInfoOutput{ Account: acc, }, nil } ================================================ FILE: pkg/appstore/appstore_account_info_test.go ================================================ package appstore import ( "errors" "fmt" "github.com/majd/ipatool/v2/pkg/keychain" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (AccountInfo)", func() { var ( ctrl *gomock.Controller appstore AppStore mockKeychain *keychain.MockKeychain ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) appstore = NewAppStore(Args{ Keychain: mockKeychain, }) }) AfterEach(func() { ctrl.Finish() }) When("keychain returns valid data", func() { const ( testEmail = "test-email" testName = "test-name" ) BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). Return([]byte(fmt.Sprintf("{\"email\": \"%s\", \"name\": \"%s\"}", testEmail, testName)), nil) }) It("returns output", func() { out, err := appstore.AccountInfo() Expect(err).ToNot(HaveOccurred()) Expect(out.Account.Email).To(Equal(testEmail)) Expect(out.Account.Name).To(Equal(testName)) }) }) When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). Return([]byte{}, errors.New("")) }) It("returns wrapped error", func() { _, err := appstore.AccountInfo() Expect(err).To(HaveOccurred()) }) }) When("keychain returns invalid data", func() { BeforeEach(func() { mockKeychain.EXPECT(). Get("account"). Return([]byte("..."), nil) }) It("fails to unmarshall JSON data", func() { _, err := appstore.AccountInfo() Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/appstore/appstore_bag.go ================================================ package appstore import ( "fmt" gohttp "net/http" "strings" "github.com/majd/ipatool/v2/pkg/http" ) type BagInput struct{} type BagOutput struct { AuthEndpoint string } func (t *appstore) Bag(input BagInput) (BagOutput, error) { macAddr, err := t.machine.MacAddress() if err != nil { return BagOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") req := t.bagRequest(guid) res, err := t.bagClient.Send(req) if err != nil { return BagOutput{}, fmt.Errorf("failed to send http request: %w", err) } if res.StatusCode != gohttp.StatusOK { return BagOutput{}, fmt.Errorf("received unexpected status code: %d", res.StatusCode) } return BagOutput{ AuthEndpoint: res.Data.URLBag.AuthEndpoint, }, nil } type bagResult struct { URLBag urlBag `plist:"urlBag,omitempty"` } type urlBag struct { AuthEndpoint string `plist:"authenticateAccount,omitempty"` } func (*appstore) bagRequest(guid string) http.Request { return http.Request{ URL: fmt.Sprintf("https://%s%s?guid=%s", PrivateInitDomain, PrivateInitPath, guid), Method: http.MethodGET, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Accept": "application/xml", }, } } ================================================ FILE: pkg/appstore/appstore_bag_test.go ================================================ package appstore import ( "errors" gohttp "net/http" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Bag)", func() { var ( ctrl *gomock.Controller mockBagClient *http.MockClient[bagResult] mockMachine *machine.MockMachine as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockBagClient = http.NewMockClient[bagResult](ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ bagClient: mockBagClient, machine: mockMachine, } }) AfterEach(func() { ctrl.Finish() }) When("fails to read machine MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("mac error")) }) It("returns error", func() { _, err := as.Bag(BagInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get mac address")) }) }) When("request fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockBagClient.EXPECT(). Send(gomock.Any()). Return(http.Result[bagResult]{}, errors.New("request error")) }) It("returns wrapped error", func() { _, err := as.Bag(BagInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to send http request")) }) }) When("request returns non-200 status code", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockBagClient.EXPECT(). Send(gomock.Any()). Return(http.Result[bagResult]{ StatusCode: gohttp.StatusForbidden, }, nil) }) It("returns error", func() { _, err := as.Bag(BagInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("received unexpected status code")) }) }) When("request is successful", func() { const testAuthEndpoint = "https://example.com" BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("aa:bb:cc:dd:ee:ff", nil) mockBagClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { Expect(req.Method).To(Equal(http.MethodGET)) Expect(req.URL).To(Equal("https://init.itunes.apple.com/bag.xml?guid=AABBCCDDEEFF")) Expect(req.ResponseFormat).To(Equal(http.ResponseFormatXML)) Expect(req.Headers).To(HaveKeyWithValue("Accept", "application/xml")) }). Return(http.Result[bagResult]{ StatusCode: gohttp.StatusOK, Data: bagResult{ URLBag: urlBag{ AuthEndpoint: testAuthEndpoint, }, }, }, nil) }) It("returns output", func() { out, err := as.Bag(BagInput{}) Expect(err).ToNot(HaveOccurred()) Expect(out.AuthEndpoint).To(Equal(testAuthEndpoint)) }) }) }) ================================================ FILE: pkg/appstore/appstore_download.go ================================================ package appstore import ( "archive/zip" "errors" "fmt" "io" "os" "strconv" "strings" "github.com/majd/ipatool/v2/pkg/http" "github.com/schollz/progressbar/v3" "howett.net/plist" ) var ( ErrLicenseRequired = errors.New("license is required") ) type DownloadInput struct { Account Account App App OutputPath string Progress *progressbar.ProgressBar ExternalVersionID string } type DownloadOutput struct { DestinationPath string Sinfs []Sinf } func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { macAddr, err := t.machine.MacAddress() if err != nil { return DownloadOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") req := t.downloadRequest(input.Account, input.App, guid, input.ExternalVersionID) res, err := t.downloadClient.Send(req) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to send http request: %w", err) } if res.Data.FailureType == FailureTypePasswordTokenExpired { return DownloadOutput{}, ErrPasswordTokenExpired } if res.Data.FailureType == FailureTypeLicenseNotFound { return DownloadOutput{}, ErrLicenseRequired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { return DownloadOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) } if len(res.Data.Items) == 0 { return DownloadOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } item := res.Data.Items[0] version := "unknown" // Read the version from the item metadata if itemVersion, ok := item.Metadata["bundleShortVersionString"]; ok { version = fmt.Sprintf("%v", itemVersion) } destination, err := t.resolveDestinationPath(input.App, version, input.OutputPath) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to resolve destination path: %w", err) } err = t.downloadFile(item.URL, fmt.Sprintf("%s.tmp", destination), input.Progress) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err) } err = t.applyPatches(item, input.Account, fmt.Sprintf("%s.tmp", destination), destination) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) } err = t.os.Remove(fmt.Sprintf("%s.tmp", destination)) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to remove file: %w", err) } return DownloadOutput{ DestinationPath: destination, Sinfs: item.Sinfs, }, nil } type downloadItemResult struct { HashMD5 string `plist:"md5,omitempty"` URL string `plist:"URL,omitempty"` Sinfs []Sinf `plist:"sinfs,omitempty"` Metadata map[string]interface{} `plist:"metadata,omitempty"` } type downloadResult struct { FailureType string `plist:"failureType,omitempty"` CustomerMessage string `plist:"customerMessage,omitempty"` Items []downloadItemResult `plist:"songList,omitempty"` } func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar) error { req, err := t.httpClient.NewRequest("GET", src, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) } file, err := t.os.OpenFile(dst, os.O_CREATE|os.O_RDWR, 0644) if err != nil { return fmt.Errorf("failed to open file: %w", err) } defer file.Close() stat, err := t.os.Stat(dst) if err != nil { return fmt.Errorf("failed to get file info: %w", err) } if req != nil && stat != nil { req.Header.Add("range", fmt.Sprintf("bytes=%d-", stat.Size())) } res, err := t.httpClient.Do(req) if err != nil { return fmt.Errorf("request failed: %w", err) } defer res.Body.Close() if progress != nil { progress.ChangeMax64(res.ContentLength + stat.Size()) err = progress.Set64(stat.Size()) if err != nil { return fmt.Errorf("can not set bar progress: %w", err) } _, err = file.Seek(0, io.SeekEnd) if err != nil { return fmt.Errorf("can not seek file: %w", err) } _, err = io.Copy(io.MultiWriter(file, progress), res.Body) } else { _, err = io.Copy(file, res.Body) } if err != nil { return fmt.Errorf("failed to write file: %w", err) } return nil } func (*appstore) downloadRequest(acc Account, app App, guid string, externalVersionID string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, "salableAdamId": app.ID, } if externalVersionID != "" { payload["externalVersionId"] = externalVersionID } podPrefix := "" if acc.Pod != "" { podPrefix = "p" + acc.Pod + "-" } return http.Request{ URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-apple-plist", "iCloud-DSID": acc.DirectoryServicesID, "X-Dsid": acc.DirectoryServicesID, }, Payload: &http.XMLPayload{ Content: payload, }, } } func fileName(app App, version string) string { var parts []string if app.BundleID != "" { parts = append(parts, app.BundleID) } if app.ID != 0 { parts = append(parts, strconv.FormatInt(app.ID, 10)) } if version != "" { parts = append(parts, version) } return fmt.Sprintf("%s.ipa", strings.Join(parts, "_")) } func (t *appstore) resolveDestinationPath(app App, version string, path string) (string, error) { file := fileName(app, version) if path == "" { workdir, err := t.os.Getwd() if err != nil { return "", fmt.Errorf("failed to get current directory: %w", err) } return fmt.Sprintf("%s/%s", workdir, file), nil } isDir, err := t.isDirectory(path) if err != nil { return "", fmt.Errorf("failed to determine whether path is a directory: %w", err) } if isDir { return fmt.Sprintf("%s/%s", path, file), nil } return path, nil } func (t *appstore) isDirectory(path string) (bool, error) { info, err := t.os.Stat(path) if err != nil && !os.IsNotExist(err) { return false, fmt.Errorf("failed to read file metadata: %w", err) } if info == nil { return false, nil } return info.IsDir(), nil } func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error { srcZip, err := zip.OpenReader(src) if err != nil { return fmt.Errorf("failed to open zip reader: %w", err) } defer srcZip.Close() dstFile, err := t.os.OpenFile(dst, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open file: %w", err) } dstZip := zip.NewWriter(dstFile) defer dstZip.Close() err = t.replicateZip(srcZip, dstZip) if err != nil { return fmt.Errorf("failed to replicate zip: %w", err) } err = t.writeMetadata(item.Metadata, acc, dstZip) if err != nil { return fmt.Errorf("failed to write metadata: %w", err) } return nil } func (t *appstore) writeMetadata(metadata map[string]interface{}, acc Account, zip *zip.Writer) error { metadata["apple-id"] = acc.Email metadata["userName"] = acc.Email metadataFile, err := zip.Create("iTunesMetadata.plist") if err != nil { return fmt.Errorf("failed to create file: %w", err) } data, err := plist.Marshal(metadata, plist.BinaryFormat) if err != nil { return fmt.Errorf("failed to marshal data: %w", err) } _, err = metadataFile.Write(data) if err != nil { return fmt.Errorf("failed to write data: %w", err) } return nil } ================================================ FILE: pkg/appstore/appstore_download_test.go ================================================ package appstore import ( "archive/zip" "errors" "fmt" "io" "io/fs" gohttp "net/http" "os" "strings" "time" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/util/machine" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" "howett.net/plist" ) type dummyFileInfo struct{} func (d *dummyFileInfo) Name() string { return "dummy" } func (d *dummyFileInfo) Size() int64 { return 0 } func (d *dummyFileInfo) Mode() fs.FileMode { return 0 } func (d *dummyFileInfo) ModTime() time.Time { return time.Time{} } func (d *dummyFileInfo) IsDir() bool { return false } func (d *dummyFileInfo) Sys() interface{} { return nil } var _ = Describe("AppStore (Download)", func() { var ( ctrl *gomock.Controller mockKeychain *keychain.MockKeychain mockDownloadClient *http.MockClient[downloadResult] mockPurchaseClient *http.MockClient[purchaseResult] mockLoginClient *http.MockClient[loginResult] mockHTTPClient *http.MockClient[interface{}] mockOS *operatingsystem.MockOperatingSystem mockMachine *machine.MockMachine as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) mockDownloadClient = http.NewMockClient[downloadResult](ctrl) mockLoginClient = http.NewMockClient[loginResult](ctrl) mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockHTTPClient = http.NewMockClient[interface{}](ctrl) mockOS = operatingsystem.NewMockOperatingSystem(ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, loginClient: mockLoginClient, purchaseClient: mockPurchaseClient, downloadClient: mockDownloadClient, httpClient: mockHTTPClient, machine: mockMachine, os: mockOS, } }) AfterEach(func() { ctrl.Finish() }) When("fails to read MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{}, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("request uses a custom pod", func() { const ( testPod = "42" testGUID = "001122334455" ) BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID Expect(req.URL).To(Equal(expectedURL)) }). Return(http.Result[downloadResult]{}, errors.New("")) }) It("sends the download request to the pod-specific host", func() { _, err := as.Download(DownloadInput{ Account: Account{ Pod: testPod, }, }) Expect(err).To(HaveOccurred()) }) }) When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("license is missing", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypeLicenseNotFound, }, }, nil) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) }) When("response contains customer message", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "test-failure", CustomerMessage: errors.New("").Error(), }, }, nil) }) It("returns customer message as error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("response does not contain customer message", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "test-failure", }, }, nil) }) It("returns generic error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) }) When("store API returns no items", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{}, }, }, nil) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("fails to resolve output path", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{{}}, }, }, nil) mockOS.EXPECT(). Stat(gomock.Any()). Return(nil, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{ OutputPath: "test-out", }) Expect(err).To(HaveOccurred()) }) }) When("fails to download file", func() { BeforeEach(func() { mockOS.EXPECT(). Getwd(). Return("", nil) mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{{}}, }, }, nil) }) When("fails to create download request", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(nil, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("fails to open file", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(nil, nil) mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("fails to get file info", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(nil, nil) mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil) mockOS.EXPECT(). Stat(gomock.Any()). Return(&dummyFileInfo{}, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(&gohttp.Request{Header: map[string][]string{}}, nil) mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil) mockOS.EXPECT(). Stat(gomock.Any()). Return(&dummyFileInfo{}, nil) mockHTTPClient.EXPECT(). Do(gomock.Any()). Return(&gohttp.Response{Body: io.NopCloser(strings.NewReader(""))}, errors.New("")) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) When("fails to write data to file", func() { BeforeEach(func() { mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(&gohttp.Request{Header: map[string][]string{}}, nil) mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, nil) mockOS.EXPECT(). Stat(gomock.Any()). Return(&dummyFileInfo{}, nil) mockHTTPClient.EXPECT(). Do(gomock.Any()). Return(&gohttp.Response{ Body: io.NopCloser(strings.NewReader("ping")), }, nil) }) It("returns error", func() { _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) }) }) }) When("successfully downloads file", func() { var testFile *os.File BeforeEach(func() { var err error testFile, err = os.CreateTemp("", "test_file") Expect(err).ToNot(HaveOccurred()) mockMachine.EXPECT(). MacAddress(). Return("", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "bundleShortVersionString": "xyz", }, Sinfs: []Sinf{ { ID: 0, Data: []byte("test-sinf-data"), }, }, }, }, }, }, nil) mockHTTPClient.EXPECT(). NewRequest("GET", gomock.Any(), nil). Return(&gohttp.Request{Header: map[string][]string{}}, nil) mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(testFile, nil) mockOS.EXPECT(). Stat(gomock.Any()). Return(&dummyFileInfo{}, nil) mockHTTPClient.EXPECT(). Do(gomock.Any()). Return(&gohttp.Response{ Body: io.NopCloser(strings.NewReader("ping")), }, nil) }) AfterEach(func() { err := os.Remove(testFile.Name()) Expect(err).ToNot(HaveOccurred()) }) It("writes data to file", func() { mockOS.EXPECT(). Getwd(). Return("", nil) _, err := as.Download(DownloadInput{}) Expect(err).To(HaveOccurred()) testData, err := os.ReadFile(testFile.Name()) Expect(err).ToNot(HaveOccurred()) Expect(string(testData)).To(Equal("ping")) }) When("successfully applies patches", func() { var ( tmpFile *os.File outputPath string ) BeforeEach(func() { var err error tmpFile, err = os.OpenFile(fmt.Sprintf("%s.tmp", testFile.Name()), os.O_CREATE|os.O_WRONLY, 0644) Expect(err).ToNot(HaveOccurred()) outputPath = strings.TrimSuffix(tmpFile.Name(), ".tmp") mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(os.OpenFile) mockOS.EXPECT(). Stat(gomock.Any()). Return(nil, nil) mockOS.EXPECT(). Remove(tmpFile.Name()). Return(nil) zipFile := zip.NewWriter(tmpFile) w, err := zipFile.Create("Payload/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) info, err := plist.Marshal(map[string]interface{}{ "CFBundleExecutable": "Test", }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) _, err = w.Write(info) Expect(err).ToNot(HaveOccurred()) err = zipFile.Close() Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { err := os.Remove(tmpFile.Name()) Expect(err).ToNot(HaveOccurred()) }) It("succeeds", func() { out, err := as.Download(DownloadInput{ OutputPath: outputPath, }) Expect(err).ToNot(HaveOccurred()) Expect(out.DestinationPath).ToNot(BeEmpty()) }) }) }) }) ================================================ FILE: pkg/appstore/appstore_get_version_metadata.go ================================================ package appstore import ( "errors" "fmt" "strings" "time" "github.com/majd/ipatool/v2/pkg/http" ) type GetVersionMetadataInput struct { Account Account App App VersionID string } type GetVersionMetadataOutput struct { DisplayVersion string ReleaseDate time.Time } func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error) { macAddr, err := t.machine.MacAddress() if err != nil { return GetVersionMetadataOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID) res, err := t.downloadClient.Send(req) if err != nil { return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) } if res.Data.FailureType == FailureTypePasswordTokenExpired { return GetVersionMetadataOutput{}, ErrPasswordTokenExpired } if res.Data.FailureType == FailureTypeLicenseNotFound { return GetVersionMetadataOutput{}, ErrLicenseRequired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { return GetVersionMetadataOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) } if len(res.Data.Items) == 0 { return GetVersionMetadataOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } item := res.Data.Items[0] releaseDate, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", item.Metadata["releaseDate"])) if err != nil { return GetVersionMetadataOutput{}, fmt.Errorf("failed to parse release date: %w", err) } return GetVersionMetadataOutput{ DisplayVersion: fmt.Sprintf("%v", item.Metadata["bundleShortVersionString"]), ReleaseDate: releaseDate, }, nil } func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, "salableAdamId": app.ID, "externalVersionId": version, } podPrefix := "" if acc.Pod != "" { podPrefix = "p" + acc.Pod + "-" } return http.Request{ URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-apple-plist", "iCloud-DSID": acc.DirectoryServicesID, "X-Dsid": acc.DirectoryServicesID, }, Payload: &http.XMLPayload{ Content: payload, }, } } ================================================ FILE: pkg/appstore/appstore_get_version_metadata_test.go ================================================ package appstore import ( "errors" "time" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (GetVersionMetadata)", func() { var ( ctrl *gomock.Controller mockMachine *machine.MockMachine mockDownloadClient *http.MockClient[downloadResult] as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockMachine = machine.NewMockMachine(ctrl) mockDownloadClient = http.NewMockClient[downloadResult](ctrl) as = &appstore{ machine: mockMachine, downloadClient: mockDownloadClient, } }) AfterEach(func() { ctrl.Finish() }) When("fails to get MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("mac error")) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get mac address")) }) }) When("request fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{}, errors.New("request error")) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to send http request")) }) }) When("request uses a custom pod", func() { const ( testPod = "42" testGUID = "001122334455" ) BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID Expect(req.URL).To(Equal(expectedURL)) }). Return(http.Result[downloadResult]{}, errors.New("request error")) }) It("sends the request to the pod-specific host", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{ Account: Account{ Pod: testPod, }, }) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to send http request")) }) }) When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(Equal(ErrPasswordTokenExpired)) }) }) When("license is missing", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypeLicenseNotFound, }, }, nil) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(Equal(ErrLicenseRequired)) }) }) When("store API returns error", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) }) When("response contains customer message", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "SOME_ERROR", CustomerMessage: "Customer error message", }, }, nil) }) It("returns customer message as error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("Customer error message")) }) }) When("response does not contain customer message", func() { BeforeEach(func() { mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "SOME_ERROR", }, }, nil) }) It("returns generic error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("SOME_ERROR")) }) }) }) When("store API returns no items", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{}, }, }, nil) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("invalid response")) }) }) When("fails to parse release date", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "releaseDate": "invalid-date", }, }, }, }, }, nil) }) It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to parse release date")) }) }) When("successfully gets version metadata", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "releaseDate": "2024-03-20T12:00:00Z", "bundleShortVersionString": "1.0.0", }, }, }, }, }, nil) }) It("returns version metadata", func() { output, err := as.GetVersionMetadata(GetVersionMetadataInput{ Account: Account{ DirectoryServicesID: "test-dsid", }, App: App{ ID: 1234567890, }, VersionID: "test-version", }) Expect(err).NotTo(HaveOccurred()) Expect(output.DisplayVersion).To(Equal("1.0.0")) Expect(output.ReleaseDate).To(Equal(time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC))) }) }) }) ================================================ FILE: pkg/appstore/appstore_list_versions.go ================================================ package appstore import ( "errors" "fmt" "strings" "github.com/majd/ipatool/v2/pkg/http" ) type ListVersionsInput struct { Account Account App App } type ListVersionsOutput struct { ExternalVersionIdentifiers []string LatestExternalVersionID string } func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, error) { macAddr, err := t.machine.MacAddress() if err != nil { return ListVersionsOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") req := t.listVersionsRequest(input.Account, input.App, guid) res, err := t.downloadClient.Send(req) if err != nil { return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) } if res.Data.FailureType == FailureTypePasswordTokenExpired { return ListVersionsOutput{}, ErrPasswordTokenExpired } if res.Data.FailureType == FailureTypeLicenseNotFound { return ListVersionsOutput{}, ErrLicenseRequired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("received error: %s", res.Data.FailureType), res) } if len(res.Data.Items) == 0 { return ListVersionsOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } item := res.Data.Items[0] rawIdentifiers, ok := item.Metadata["softwareVersionExternalIdentifiers"].([]interface{}) if !ok { return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get version identifiers from item metadata"), item.Metadata) } externalVersionIdentifiers := make([]string, len(rawIdentifiers)) for i, val := range rawIdentifiers { externalVersionIdentifiers[i] = fmt.Sprintf("%v", val) } latestExternalVersionID := item.Metadata["softwareVersionExternalIdentifier"] if latestExternalVersionID == nil { return ListVersionsOutput{}, NewErrorWithMetadata(fmt.Errorf("failed to get latest version from item metadata"), item.Metadata) } return ListVersionsOutput{ ExternalVersionIdentifiers: externalVersionIdentifiers, LatestExternalVersionID: fmt.Sprintf("%v", latestExternalVersionID), }, nil } func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, "salableAdamId": app.ID, } podPrefix := "" if acc.Pod != "" { podPrefix = "p" + acc.Pod + "-" } return http.Request{ URL: fmt.Sprintf("https://%s%s%s?guid=%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathDownload, guid), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-apple-plist", "iCloud-DSID": acc.DirectoryServicesID, "X-Dsid": acc.DirectoryServicesID, }, Payload: &http.XMLPayload{ Content: payload, }, } } ================================================ FILE: pkg/appstore/appstore_list_versions_test.go ================================================ package appstore import ( "errors" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (ListVersions)", func() { var ( ctrl *gomock.Controller mockDownloadClient *http.MockClient[downloadResult] mockMachine *machine.MockMachine as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockDownloadClient = http.NewMockClient[downloadResult](ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ downloadClient: mockDownloadClient, machine: mockMachine, } }) AfterEach(func() { ctrl.Finish() }) When("fails to get MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("")) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{}, errors.New("")) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) }) }) When("request uses a custom pod", func() { const ( testPod = "42" testGUID = "001122334455" ) BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathDownload + "?guid=" + testGUID Expect(req.URL).To(Equal(expectedURL)) }). Return(http.Result[downloadResult]{}, errors.New("")) }) It("sends the request to the pod-specific host", func() { _, err := as.ListVersions(ListVersionsInput{ Account: Account{ Pod: testPod, }, }) Expect(err).To(HaveOccurred()) }) }) When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) }) }) When("license is required", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: FailureTypeLicenseNotFound, }, }, nil) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) }) }) When("store API returns error with customer message", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "test-failure", CustomerMessage: "test error message", }, }, nil) }) It("returns error with customer message", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("test error message")) }) }) When("store API returns error without customer message", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ FailureType: "test-failure", }, }, nil) }) It("returns error with failure type", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("test-failure")) }) }) When("store API returns no items", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{}, }, }, nil) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) }) }) When("version identifiers not found in metadata", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "someOtherKey": "someValue", }, }, }, }, }, nil) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get version identifiers from item metadata")) }) }) When("latest version not found in metadata", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "softwareVersionExternalIdentifiers": []interface{}{"12345678", "87654321"}, }, }, }, }, }, nil) }) It("returns error", func() { _, err := as.ListVersions(ListVersionsInput{}) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("failed to get latest version from item metadata")) }) }) When("successfully lists versions", func() { const ( testVersion1 = "12345678" testVersion2 = "87654321" testLatest = "87654321" ) BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockDownloadClient.EXPECT(). Send(gomock.Any()). Return(http.Result[downloadResult]{ Data: downloadResult{ Items: []downloadItemResult{ { Metadata: map[string]interface{}{ "softwareVersionExternalIdentifiers": []interface{}{testVersion1, testVersion2}, "softwareVersionExternalIdentifier": testLatest, }, }, }, }, }, nil) }) It("returns versions", func() { out, err := as.ListVersions(ListVersionsInput{}) Expect(err).ToNot(HaveOccurred()) Expect(out.ExternalVersionIdentifiers).To(Equal([]string{testVersion1, testVersion2})) Expect(out.LatestExternalVersionID).To(Equal(testLatest)) }) }) }) ================================================ FILE: pkg/appstore/appstore_login.go ================================================ package appstore import ( "encoding/json" "errors" "fmt" gohttp "net/http" "strconv" "strings" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util" ) var ( ErrAuthCodeRequired = errors.New("auth code is required") ) type LoginInput struct { Email string Password string AuthCode string Endpoint string } type LoginOutput struct { Account Account } func (t *appstore) Login(input LoginInput) (LoginOutput, error) { macAddr, err := t.machine.MacAddress() if err != nil { return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") acc, err := t.login(input.Email, input.Password, input.AuthCode, guid, input.Endpoint) if err != nil { return LoginOutput{}, err } return LoginOutput{ Account: acc, }, nil } type loginAddressResult struct { FirstName string `plist:"firstName,omitempty"` LastName string `plist:"lastName,omitempty"` } type loginAccountResult struct { Email string `plist:"appleId,omitempty"` Address loginAddressResult `plist:"address,omitempty"` } type loginResult struct { FailureType string `plist:"failureType,omitempty"` CustomerMessage string `plist:"customerMessage,omitempty"` Account loginAccountResult `plist:"accountInfo,omitempty"` DirectoryServicesID string `plist:"dsPersonId,omitempty"` PasswordToken string `plist:"passwordToken,omitempty"` } func (t *appstore) login(email, password, authCode, guid, endpoint string) (Account, error) { redirect := "" var ( err error res http.Result[loginResult] ) retry := true for attempt := 1; retry && attempt <= 4; attempt++ { request := t.loginRequest(email, password, authCode, guid, endpoint, attempt) request.URL, _ = util.IfEmpty(redirect, request.URL), "" res, err = t.loginClient.Send(request) if err != nil { return Account{}, fmt.Errorf("request failed: %w", err) } if retry, redirect, err = t.parseLoginResponse(&res, attempt, authCode); err != nil { return Account{}, err } } if retry { return Account{}, NewErrorWithMetadata(errors.New("too many attempts"), res) } sf, err := res.GetHeader(HTTPHeaderStoreFront) if err != nil { return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get storefront header: %w", err), res) } pod, err := res.GetHeader(HTTPHeaderPod) if err != nil && !errors.Is(err, http.ErrHeaderNotFound) { return Account{}, NewErrorWithMetadata(fmt.Errorf("failed to get pod header: %w", err), res) } addr := res.Data.Account.Address acc := Account{ Name: strings.Join([]string{addr.FirstName, addr.LastName}, " "), Email: res.Data.Account.Email, PasswordToken: res.Data.PasswordToken, DirectoryServicesID: res.Data.DirectoryServicesID, StoreFront: sf, Password: password, Pod: pod, } data, err := json.Marshal(acc) if err != nil { return Account{}, fmt.Errorf("failed to marshal json: %w", err) } err = t.keychain.Set("account", data) if err != nil { return Account{}, fmt.Errorf("failed to save account in keychain: %w", err) } return acc, nil } func (t *appstore) parseLoginResponse(res *http.Result[loginResult], attempt int, authCode string) (bool, string, error) { var ( retry bool redirect string err error ) if res.StatusCode == gohttp.StatusFound { if redirect, err = res.GetHeader("location"); err != nil { err = fmt.Errorf("failed to retrieve redirect location: %w", err) } else { retry = true } } else if attempt == 1 && res.Data.FailureType == FailureTypeInvalidCredentials { retry = true } else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { err = ErrAuthCodeRequired } else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageAccountDisabled { err = NewErrorWithMetadata(errors.New("account is disabled"), res) } else if res.Data.FailureType != "" { if res.Data.CustomerMessage != "" { err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) } else { err = NewErrorWithMetadata(errors.New("something went wrong"), res) } } else if res.StatusCode != gohttp.StatusOK || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" { err = NewErrorWithMetadata(errors.New("something went wrong"), res) } return retry, redirect, err } func (t *appstore) loginRequest(email, password, authCode, guid, endpoint string, attempt int) http.Request { return http.Request{ Method: http.MethodPOST, URL: endpoint, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-www-form-urlencoded", }, Payload: &http.XMLPayload{ Content: map[string]interface{}{ "appleId": email, "attempt": strconv.Itoa(attempt), "guid": guid, "password": fmt.Sprintf("%s%s", password, strings.ReplaceAll(authCode, " ", "")), "rmp": "0", "why": "signIn", }, }, } } ================================================ FILE: pkg/appstore/appstore_login_test.go ================================================ package appstore import ( "encoding/json" "errors" "fmt" "strings" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Login)", func() { const ( testPassword = "test-password" testEmail = "test-email" testFirstName = "test-first-name" testLastName = "test-last-name" testPod = "42" ) var ( ctrl *gomock.Controller as AppStore mockKeychain *keychain.MockKeychain mockClient *http.MockClient[loginResult] mockMachine *machine.MockMachine ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) mockClient = http.NewMockClient[loginResult](ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, loginClient: mockClient, machine: mockMachine, } }) AfterEach(func() { ctrl.Finish() }) When("fails to read Machine's MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("")) }) It("returns error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) }) }) When("successfully reads machine's MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) }) When("client returns error", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{}, errors.New("")) }) It("returns wrapped error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) }) }) When("store API returns invalid first response", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ Data: loginResult{ FailureType: FailureTypeInvalidCredentials, CustomerMessage: "test", }, }, nil). Times(2) }) It("retries one more time", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) }) }) When("store API returns error", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ Data: loginResult{ FailureType: "random-error", }, }, nil) }) It("returns error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) }) }) When("store API indicates account is disabled", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ Data: loginResult{ CustomerMessage: CustomerMessageAccountDisabled, }, }, nil) }) It("returns account disabled error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("account is disabled")) }) }) When("store API requires 2FA code", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ Data: loginResult{ FailureType: "", CustomerMessage: CustomerMessageBadLogin, }, }, nil) }) It("returns ErrAuthCodeRequired error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(Equal(ErrAuthCodeRequired)) }) }) When("store API redirects", func() { const ( testRedirectLocation = "https://test-redirect-url.com" ) BeforeEach(func() { firstCall := mockClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) x := req.Payload.(*http.XMLPayload) Expect(x.Content).To(HaveKeyWithValue("attempt", "1")) }). Return(http.Result[loginResult]{ StatusCode: 302, Headers: map[string]string{"Location": testRedirectLocation}, }, nil) secondCall := mockClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { Expect(req.URL).To(Equal(testRedirectLocation)) Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) x := req.Payload.(*http.XMLPayload) Expect(x.Content).To(HaveKeyWithValue("attempt", "2")) }). Return(http.Result[loginResult]{}, errors.New("test complete")) gomock.InOrder(firstCall, secondCall) }) It("follows the redirect and increments attempt", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(MatchError("request failed: test complete")) }) }) When("store API redirects too much", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ StatusCode: 302, Headers: map[string]string{"Location": "hello"}, }, nil). Times(4) }) It("bails out", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(MatchError("too many attempts")) }) }) When("store API returns valid response", func() { const ( testPasswordToken = "test-password-token" testDirectoryServicesID = "directory-services-id" testStoreFront = "test-storefront" ) BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ StatusCode: 200, Headers: map[string]string{ HTTPHeaderStoreFront: testStoreFront, HTTPHeaderPod: testPod, }, Data: loginResult{ PasswordToken: testPasswordToken, DirectoryServicesID: testDirectoryServicesID, Account: loginAccountResult{ Email: testEmail, Address: loginAddressResult{ FirstName: testFirstName, LastName: testLastName, }, }, }, }, nil) }) When("fails to save account in keychain", func() { BeforeEach(func() { mockKeychain.EXPECT(). Set("account", gomock.Any()). Do(func(key string, data []byte) { want := Account{ Name: fmt.Sprintf("%s %s", testFirstName, testLastName), Email: testEmail, PasswordToken: testPasswordToken, Password: testPassword, DirectoryServicesID: testDirectoryServicesID, StoreFront: testStoreFront, Pod: testPod, } var got Account err := json.Unmarshal(data, &got) Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(want)) }). Return(errors.New("")) }) It("returns error", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).To(HaveOccurred()) }) }) When("successfully saves account in keychain", func() { BeforeEach(func() { mockKeychain.EXPECT(). Set("account", gomock.Any()). Do(func(key string, data []byte) { want := Account{ Name: fmt.Sprintf("%s %s", testFirstName, testLastName), Email: testEmail, PasswordToken: testPasswordToken, Password: testPassword, DirectoryServicesID: testDirectoryServicesID, StoreFront: testStoreFront, Pod: testPod, } var got Account err := json.Unmarshal(data, &got) Expect(err).ToNot(HaveOccurred()) Expect(got).To(Equal(want)) }). Return(nil) }) It("returns nil", func() { out, err := as.Login(LoginInput{ Password: testPassword, }) Expect(err).ToNot(HaveOccurred()) Expect(out.Account.Email).To(Equal(testEmail)) Expect(out.Account.Name).To(Equal(strings.Join([]string{testFirstName, testLastName}, " "))) }) }) }) }) }) ================================================ FILE: pkg/appstore/appstore_lookup.go ================================================ package appstore import ( "errors" "fmt" gohttp "net/http" "net/url" "github.com/majd/ipatool/v2/pkg/http" ) type LookupInput struct { Account Account BundleID string } type LookupOutput struct { App App } func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) if err != nil { return LookupOutput{}, fmt.Errorf("failed to resolve the country code: %w", err) } request := t.lookupRequest(input.BundleID, countryCode) res, err := t.searchClient.Send(request) if err != nil { return LookupOutput{}, fmt.Errorf("request failed: %w", err) } if res.StatusCode != gohttp.StatusOK { return LookupOutput{}, NewErrorWithMetadata(errors.New("invalid response"), res) } if len(res.Data.Results) == 0 { return LookupOutput{}, errors.New("app not found") } return LookupOutput{ App: res.Data.Results[0], }, nil } func (t *appstore) lookupRequest(bundleID, countryCode string) http.Request { return http.Request{ URL: t.lookupURL(bundleID, countryCode), Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, } } func (t *appstore) lookupURL(bundleID, countryCode string) string { params := url.Values{} params.Add("entity", "software,iPadSoftware") params.Add("limit", "1") params.Add("media", "software") params.Add("bundleId", bundleID) params.Add("country", countryCode) return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathLookup, params.Encode()) } ================================================ FILE: pkg/appstore/appstore_lookup_test.go ================================================ package appstore import ( "errors" "github.com/majd/ipatool/v2/pkg/http" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Lookup)", func() { var ( ctrl *gomock.Controller mockClient *http.MockClient[searchResult] as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockClient = http.NewMockClient[searchResult](ctrl) as = &appstore{ searchClient: mockClient, } }) AfterEach(func() { ctrl.Finish() }) When("request is successful", func() { When("does not find app", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{ StatusCode: 200, Data: searchResult{ Count: 0, Results: []App{}, }, }, nil) }) It("returns error", func() { _, err := as.Lookup(LookupInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("finds app", func() { var testApp = App{ ID: 1, BundleID: "app.bundle.id", Name: "app name", Version: "1.0", Price: 0.99, } BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{ StatusCode: 200, Data: searchResult{ Count: 1, Results: []App{testApp}, }, }, nil) }) It("returns app", func() { app, err := as.Lookup(LookupInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).ToNot(HaveOccurred()) Expect(app).To(Equal(LookupOutput{App: testApp})) }) }) }) When("store front is invalid", func() { It("returns error", func() { _, err := as.Lookup(LookupInput{ Account: Account{ StoreFront: "xyz", }, }) Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{}, errors.New("")) }) It("returns error", func() { _, err := as.Lookup(LookupInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("request returns bad status code", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{ StatusCode: 400, }, nil) }) It("returns error", func() { _, err := as.Lookup(LookupInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/appstore/appstore_purchase.go ================================================ package appstore import ( "errors" "fmt" gohttp "net/http" "strings" "github.com/majd/ipatool/v2/pkg/http" ) var ( ErrPasswordTokenExpired = errors.New("password token is expired") ErrSubscriptionRequired = errors.New("subscription required") ErrTemporarilyUnavailable = errors.New("item is temporarily unavailable") ) type PurchaseInput struct { Account Account App App } func (t *appstore) Purchase(input PurchaseInput) error { macAddr, err := t.machine.MacAddress() if err != nil { return fmt.Errorf("failed to get mac address: %w", err) } guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") if input.App.Price > 0 { return errors.New("purchasing paid apps is not supported") } err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppStore) if err != nil { if err == ErrTemporarilyUnavailable { err = t.purchaseWithParams(input.Account, input.App, guid, PricingParameterAppleArcade) if err != nil { return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppleArcade, err) } return nil } return fmt.Errorf("failed to purchase item with param '%s': %w", PricingParameterAppStore, err) } return nil } type purchaseResult struct { FailureType string `plist:"failureType,omitempty"` CustomerMessage string `plist:"customerMessage,omitempty"` JingleDocType string `plist:"jingleDocType,omitempty"` Status int `plist:"status,omitempty"` } func (t *appstore) purchaseWithParams(acc Account, app App, guid string, pricingParameters string) error { req := t.purchaseRequest(acc, app, acc.StoreFront, guid, pricingParameters) res, err := t.purchaseClient.Send(req) if err != nil { return fmt.Errorf("request failed: %w", err) } if res.Data.FailureType == FailureTypeTemporarilyUnavailable { return ErrTemporarilyUnavailable } if res.Data.CustomerMessage == CustomerMessageSubscriptionRequired { return ErrSubscriptionRequired } if res.Data.FailureType == FailureTypePasswordTokenExpired { return ErrPasswordTokenExpired } if res.Data.FailureType != "" && res.Data.CustomerMessage != "" { return NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) } if res.Data.FailureType != "" { return NewErrorWithMetadata(errors.New("something went wrong"), res) } if res.StatusCode == gohttp.StatusInternalServerError { return fmt.Errorf("license already exists") } if res.Data.JingleDocType != "purchaseSuccess" || res.Data.Status != 0 { return NewErrorWithMetadata(errors.New("failed to purchase app"), res) } return nil } func (t *appstore) purchaseRequest(acc Account, app App, storeFront, guid string, pricingParameters string) http.Request { podPrefix := "" if acc.Pod != "" { podPrefix = "p" + acc.Pod + "-" } return http.Request{ URL: fmt.Sprintf("https://%s%s%s", podPrefix, PrivateAppStoreAPIDomain, PrivateAppStoreAPIPathPurchase), Method: http.MethodPOST, ResponseFormat: http.ResponseFormatXML, Headers: map[string]string{ "Content-Type": "application/x-apple-plist", "iCloud-DSID": acc.DirectoryServicesID, "X-Dsid": acc.DirectoryServicesID, "X-Apple-Store-Front": storeFront, "X-Token": acc.PasswordToken, }, Payload: &http.XMLPayload{ Content: map[string]interface{}{ "appExtVrsId": "0", "hasAskedToFulfillPreorder": "true", "buyWithoutAuthorization": "true", "hasDoneAgeCheck": "true", "guid": guid, "needDiv": "0", "origPage": fmt.Sprintf("Software-%d", app.ID), "origPageLocation": "Buy", "price": "0", "pricingParameters": pricingParameters, "productType": "C", "salableAdamId": app.ID, }, }, } } ================================================ FILE: pkg/appstore/appstore_purchase_test.go ================================================ package appstore import ( "errors" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/util/machine" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Purchase)", func() { var ( ctrl *gomock.Controller mockKeychain *keychain.MockKeychain mockMachine *machine.MockMachine mockPurchaseClient *http.MockClient[purchaseResult] mockLoginClient *http.MockClient[loginResult] as *appstore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockLoginClient = http.NewMockClient[loginResult](ctrl) mockKeychain = keychain.NewMockKeychain(ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, purchaseClient: mockPurchaseClient, loginClient: mockLoginClient, machine: mockMachine, } }) AfterEach(func() { ctrl.Finish() }) When("fails to read MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("", errors.New("")) }) It("returns error", func() { err := as.Purchase(PurchaseInput{}) Expect(err).To(HaveOccurred()) }) }) When("app is paid", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, App: App{ Price: 0.99, }, }) Expect(err).To(HaveOccurred()) }) }) When("purchase request fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{}, errors.New("")) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("request uses a custom pod", func() { const ( testPod = "42" testGUID = "001122334455" ) BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Do(func(req http.Request) { expectedURL := "https://p" + testPod + "-" + PrivateAppStoreAPIDomain + PrivateAppStoreAPIPathPurchase Expect(req.URL).To(Equal(expectedURL)) Expect(req.Payload).To(BeAssignableToTypeOf(&http.XMLPayload{})) payload := req.Payload.(*http.XMLPayload) Expect(payload.Content["guid"]).To(Equal(testGUID)) }). Return(http.Result[purchaseResult]{}, errors.New("")) }) It("sends the request to the pod-specific host", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", Pod: testPod, }, }) Expect(err).To(HaveOccurred()) }) }) When("password token is expired", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{ Data: purchaseResult{ FailureType: FailureTypePasswordTokenExpired, }, }, nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("store API returns customer error message", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{ Data: purchaseResult{ FailureType: "failure", CustomerMessage: CustomerMessageBadLogin, }, }, nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("store API returns unknown error", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{ Data: purchaseResult{ FailureType: "failure", }, }, nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("account already has a license for the app", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{ StatusCode: 500, Data: purchaseResult{}, }, nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("subscription is required", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"STDQ"}). Return(http.Result[purchaseResult]{ StatusCode: 200, Data: purchaseResult{ CustomerMessage: "This item is temporarily unavailable.", FailureType: FailureTypeTemporarilyUnavailable, }, }, nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"GAME"}). Return(http.Result[purchaseResult]{ StatusCode: 200, Data: purchaseResult{ CustomerMessage: CustomerMessageSubscriptionRequired, }, }, nil) }) It("returns error", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("successfully purchases the app", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"STDQ"}). Return(http.Result[purchaseResult]{ StatusCode: 200, Data: purchaseResult{ CustomerMessage: "This item is temporarily unavailable.", FailureType: FailureTypeTemporarilyUnavailable, }, }, nil) mockPurchaseClient.EXPECT(). Send(pricingParametersMatcher{"GAME"}). Return(http.Result[purchaseResult]{ StatusCode: 200, Data: purchaseResult{ JingleDocType: "purchaseSuccess", Status: 0, }, }, nil) }) It("returns nil", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).ToNot(HaveOccurred()) }) }) When("purchasing the app fails", func() { BeforeEach(func() { mockMachine.EXPECT(). MacAddress(). Return("00:00:00:00:00:00", nil) mockPurchaseClient.EXPECT(). Send(gomock.Any()). Return(http.Result[purchaseResult]{ StatusCode: 200, Data: purchaseResult{ JingleDocType: "failure", Status: -1, }, }, nil) }) It("returns nil", func() { err := as.Purchase(PurchaseInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) }) type pricingParametersMatcher struct { pricingParameters string } func (p pricingParametersMatcher) Matches(in interface{}) bool { return in.(http.Request).Payload.(*http.XMLPayload).Content["pricingParameters"] == p.pricingParameters } func (p pricingParametersMatcher) String() string { return "payload pricingParameters is " + p.pricingParameters } ================================================ FILE: pkg/appstore/appstore_replicate_sinf.go ================================================ package appstore import ( "archive/zip" "bytes" "errors" "fmt" "io" "os" "path/filepath" "strings" "github.com/majd/ipatool/v2/pkg/util" "howett.net/plist" ) type Sinf struct { ID int64 `plist:"id,omitempty"` Data []byte `plist:"sinf,omitempty"` } type ReplicateSinfInput struct { Sinfs []Sinf PackagePath string } func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error { zipReader, err := zip.OpenReader(input.PackagePath) if err != nil { return errors.New("failed to open zip reader") } tmpPath := fmt.Sprintf("%s.tmp", input.PackagePath) tmpFile, err := t.os.OpenFile(tmpPath, os.O_CREATE|os.O_WRONLY, 0644) if err != nil { return fmt.Errorf("failed to open file: %w", err) } zipWriter := zip.NewWriter(tmpFile) err = t.replicateZip(zipReader, zipWriter) if err != nil { return fmt.Errorf("failed to replicate zip: %w", err) } bundleName, err := t.readBundleName(zipReader) if err != nil { return fmt.Errorf("failed to read bundle name: %w", err) } manifest, err := t.readManifestPlist(zipReader) if err != nil { return fmt.Errorf("failed to read manifest plist: %w", err) } info, err := t.readInfoPlist(zipReader) if err != nil { return fmt.Errorf("failed to read info plist: %w", err) } if manifest != nil { err = t.replicateSinfFromManifest(*manifest, zipWriter, input.Sinfs, bundleName) } else { err = t.replicateSinfFromInfo(*info, zipWriter, input.Sinfs, bundleName) } if err != nil { return fmt.Errorf("failed to replicate sinf: %w", err) } zipReader.Close() zipWriter.Close() tmpFile.Close() err = t.os.Remove(input.PackagePath) if err != nil { return fmt.Errorf("failed to remove original file: %w", err) } err = t.os.Rename(tmpPath, input.PackagePath) if err != nil { return fmt.Errorf("failed to remove original file: %w", err) } return nil } type packageManifest struct { SinfPaths []string `plist:"SinfPaths,omitempty"` } type packageInfo struct { BundleExecutable string `plist:"CFBundleExecutable,omitempty"` } func (*appstore) replicateSinfFromManifest(manifest packageManifest, zip *zip.Writer, sinfs []Sinf, bundleName string) error { zipped, err := util.Zip(sinfs, manifest.SinfPaths) if err != nil { return fmt.Errorf("failed to zip sinfs: %w", err) } for _, pair := range zipped { sp := fmt.Sprintf("Payload/%s.app/%s", bundleName, pair.Second) file, err := zip.Create(sp) if err != nil { return fmt.Errorf("failed to create file: %w", err) } _, err = file.Write(pair.First.Data) if err != nil { return fmt.Errorf("failed to write data: %w", err) } } return nil } func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinfs []Sinf, bundleName string) error { sp := fmt.Sprintf("Payload/%s.app/SC_Info/%s.sinf", bundleName, info.BundleExecutable) file, err := zip.Create(sp) if err != nil { return fmt.Errorf("failed to create file: %w", err) } _, err = file.Write(sinfs[0].Data) if err != nil { return fmt.Errorf("failed to write data: %w", err) } return nil } func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error { for _, file := range src.File { srcFile, err := file.OpenRaw() if err != nil { return fmt.Errorf("failed to open raw file: %w", err) } header := file.FileHeader dstFile, err := dst.CreateRaw(&header) if err != nil { return fmt.Errorf("failed to create raw file: %w", err) } _, err = io.Copy(dstFile, srcFile) if err != nil { return fmt.Errorf("failed to copy file: %w", err) } } return nil } func (*appstore) readInfoPlist(reader *zip.ReadCloser) (*packageInfo, error) { for _, file := range reader.File { if strings.Contains(file.Name, ".app/Info.plist") { src, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } data := new(bytes.Buffer) _, err = io.Copy(data, src) if err != nil { return nil, fmt.Errorf("failed to copy data: %w", err) } var info packageInfo _, err = plist.Unmarshal(data.Bytes(), &info) if err != nil { return nil, fmt.Errorf("failed to unmarshal data: %w", err) } return &info, nil } } return nil, nil } func (*appstore) readManifestPlist(reader *zip.ReadCloser) (*packageManifest, error) { for _, file := range reader.File { if strings.HasSuffix(file.Name, ".app/SC_Info/Manifest.plist") { src, err := file.Open() if err != nil { return nil, fmt.Errorf("failed to open file: %w", err) } data := new(bytes.Buffer) _, err = io.Copy(data, src) if err != nil { return nil, fmt.Errorf("failed to copy data: %w", err) } var manifest packageManifest _, err = plist.Unmarshal(data.Bytes(), &manifest) if err != nil { return nil, fmt.Errorf("failed to unmarshal data: %w", err) } return &manifest, nil } } return nil, nil } func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) { var bundleName string for _, file := range reader.File { if strings.Contains(file.Name, ".app/Info.plist") && !strings.Contains(file.Name, "/Watch/") { bundleName = filepath.Base(strings.TrimSuffix(file.Name, ".app/Info.plist")) break } } if bundleName == "" { return "", errors.New("could not read bundle name") } return bundleName, nil } ================================================ FILE: pkg/appstore/appstore_replicate_sinf_test.go ================================================ package appstore import ( "archive/zip" "errors" "fmt" "os" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" "github.com/majd/ipatool/v2/pkg/util/machine" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" "howett.net/plist" ) var _ = Describe("AppStore (ReplicateSinf)", func() { var ( ctrl *gomock.Controller mockKeychain *keychain.MockKeychain mockDownloadClient *http.MockClient[downloadResult] mockPurchaseClient *http.MockClient[purchaseResult] mockLoginClient *http.MockClient[loginResult] mockHTTPClient *http.MockClient[interface{}] mockOS *operatingsystem.MockOperatingSystem mockMachine *machine.MockMachine as AppStore testFile *os.File testZip *zip.Writer ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) mockDownloadClient = http.NewMockClient[downloadResult](ctrl) mockLoginClient = http.NewMockClient[loginResult](ctrl) mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockHTTPClient = http.NewMockClient[interface{}](ctrl) mockOS = operatingsystem.NewMockOperatingSystem(ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ keychain: mockKeychain, loginClient: mockLoginClient, purchaseClient: mockPurchaseClient, downloadClient: mockDownloadClient, httpClient: mockHTTPClient, machine: mockMachine, os: mockOS, } var err error testFile, err = os.CreateTemp("", "test_file") Expect(err).ToNot(HaveOccurred()) testZip = zip.NewWriter(testFile) }) JustBeforeEach(func() { testZip.Close() }) AfterEach(func() { err := os.Remove(testFile.Name()) Expect(err).ToNot(HaveOccurred()) ctrl.Finish() }) When("app includes codesign manifest", func() { BeforeEach(func() { mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(os.OpenFile) mockOS.EXPECT(). Remove(testFile.Name()). Return(nil) mockOS.EXPECT(). Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). Return(nil) manifest, err := plist.Marshal(packageManifest{ SinfPaths: []string{ "SC_Info/TestApp.sinf", }, }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) w, err := testZip.Create("Payload/Test.app/SC_Info/Manifest.plist") Expect(err).ToNot(HaveOccurred()) _, err = w.Write(manifest) Expect(err).ToNot(HaveOccurred()) w, err = testZip.Create("Payload/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) info, err := plist.Marshal(map[string]interface{}{ "CFBundleExecutable": "Test", }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) _, err = w.Write(info) Expect(err).ToNot(HaveOccurred()) w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) watchInfo, err := plist.Marshal(map[string]interface{}{ "WKWatchKitApp": true, }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) _, err = w.Write(watchInfo) Expect(err).ToNot(HaveOccurred()) }) It("replicates sinf from manifest plist", func() { err := as.ReplicateSinf(ReplicateSinfInput{ PackagePath: testFile.Name(), Sinfs: []Sinf{ { ID: 0, Data: []byte(""), }, }, }) Expect(err).ToNot(HaveOccurred()) }) }) When("app does not include codesign manifest", func() { BeforeEach(func() { mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). DoAndReturn(os.OpenFile) mockOS.EXPECT(). Remove(testFile.Name()). Return(nil) mockOS.EXPECT(). Rename(fmt.Sprintf("%s.tmp", testFile.Name()), testFile.Name()). Return(nil) w, err := testZip.Create("Payload/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) info, err := plist.Marshal(map[string]interface{}{ "CFBundleExecutable": "Test", }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) _, err = w.Write(info) Expect(err).ToNot(HaveOccurred()) w, err = testZip.Create("Payload/Test.app/Watch/Test.app/Info.plist") Expect(err).ToNot(HaveOccurred()) watchInfo, err := plist.Marshal(map[string]interface{}{ "WKWatchKitApp": true, }, plist.BinaryFormat) Expect(err).ToNot(HaveOccurred()) _, err = w.Write(watchInfo) Expect(err).ToNot(HaveOccurred()) }) It("replicates sinf", func() { err := as.ReplicateSinf(ReplicateSinfInput{ PackagePath: testFile.Name(), Sinfs: []Sinf{ { ID: 0, Data: []byte(""), }, }, }) Expect(err).ToNot(HaveOccurred()) }) }) When("fails to open file", func() { BeforeEach(func() { mockOS.EXPECT(). OpenFile(gomock.Any(), gomock.Any(), gomock.Any()). Return(nil, errors.New("")) }) It("returns error", func() { err := as.ReplicateSinf(ReplicateSinfInput{ PackagePath: testFile.Name(), }) Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/appstore/appstore_revoke.go ================================================ package appstore import ( "fmt" ) func (t *appstore) Revoke() error { err := t.keychain.Remove("account") if err != nil { return fmt.Errorf("failed to remove account from keychain: %w", err) } return nil } ================================================ FILE: pkg/appstore/appstore_revoke_test.go ================================================ package appstore import ( "errors" "github.com/majd/ipatool/v2/pkg/keychain" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Revoke)", func() { var ( ctrl *gomock.Controller appstore AppStore mockKeychain *keychain.MockKeychain ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeychain = keychain.NewMockKeychain(ctrl) appstore = NewAppStore(Args{ Keychain: mockKeychain, }) }) AfterEach(func() { ctrl.Finish() }) When("keychain removes item", func() { BeforeEach(func() { mockKeychain.EXPECT(). Remove("account"). Return(nil) }) It("returns data", func() { err := appstore.Revoke() Expect(err).ToNot(HaveOccurred()) }) }) When("keychain returns error", func() { BeforeEach(func() { mockKeychain.EXPECT(). Remove("account"). Return(errors.New("")) }) It("returns wrapped error", func() { err := appstore.Revoke() Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/appstore/appstore_search.go ================================================ package appstore import ( "errors" "fmt" gohttp "net/http" "net/url" "strconv" "github.com/majd/ipatool/v2/pkg/http" ) type SearchInput struct { Account Account Term string Limit int64 } type SearchOutput struct { Count int Results []App } func (t *appstore) Search(input SearchInput) (SearchOutput, error) { countryCode, err := countryCodeFromStoreFront(input.Account.StoreFront) if err != nil { return SearchOutput{}, fmt.Errorf("country code is invalid: %w", err) } request := t.searchRequest(input.Term, countryCode, input.Limit) res, err := t.searchClient.Send(request) if err != nil { return SearchOutput{}, fmt.Errorf("request failed: %w", err) } if res.StatusCode != gohttp.StatusOK { return SearchOutput{}, NewErrorWithMetadata(errors.New("request failed"), res) } return SearchOutput{ Count: res.Data.Count, Results: res.Data.Results, }, nil } type searchResult struct { Count int `json:"resultCount,omitempty"` Results []App `json:"results,omitempty"` } func (t *appstore) searchRequest(term, countryCode string, limit int64) http.Request { return http.Request{ URL: t.searchURL(term, countryCode, limit), Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, } } func (t *appstore) searchURL(term, countryCode string, limit int64) string { params := url.Values{} params.Add("entity", "software,iPadSoftware") params.Add("limit", strconv.Itoa(int(limit))) params.Add("media", "software") params.Add("term", term) params.Add("country", countryCode) return fmt.Sprintf("https://%s%s?%s", iTunesAPIDomain, iTunesAPIPathSearch, params.Encode()) } ================================================ FILE: pkg/appstore/appstore_search_test.go ================================================ package appstore import ( "errors" "github.com/majd/ipatool/v2/pkg/http" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("AppStore (Search)", func() { var ( ctrl *gomock.Controller mockClient *http.MockClient[searchResult] as AppStore ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockClient = http.NewMockClient[searchResult](ctrl) as = &appstore{ searchClient: mockClient, } }) AfterEach(func() { ctrl.Finish() }) When("request is successful", func() { const ( testID = 0 testBundleID = "test-bundle-id" testName = "test-name" testVersion = "test-version" testPrice = 0.0 ) BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{ StatusCode: 200, Data: searchResult{ Count: 1, Results: []App{ { ID: testID, BundleID: testBundleID, Name: testName, Version: testVersion, Price: testPrice, }, }, }, }, nil) }) It("returns output", func() { out, err := as.Search(SearchInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).ToNot(HaveOccurred()) Expect(out.Count).To(Equal(1)) Expect(out.Results).To(HaveLen(1)) Expect(out.Results[0]).To(Equal(App{ ID: testID, BundleID: testBundleID, Name: testName, Version: testVersion, Price: testPrice, })) }) }) When("store front is invalid", func() { It("returns error", func() { _, err := as.Search(SearchInput{ Account: Account{ StoreFront: "xyz", }, }) Expect(err).To(HaveOccurred()) }) }) When("request fails", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{}, errors.New("")) }) It("returns error", func() { _, err := as.Search(SearchInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) When("request returns bad status code", func() { BeforeEach(func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[searchResult]{ StatusCode: 400, }, nil) }) It("returns error", func() { _, err := as.Search(SearchInput{ Account: Account{ StoreFront: "143441", }, }) Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/appstore/appstore_test.go ================================================ package appstore import ( "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestAppStore(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "App Store Suite") } ================================================ FILE: pkg/appstore/constants.go ================================================ package appstore const ( FailureTypeInvalidCredentials = "-5000" FailureTypePasswordTokenExpired = "2034" FailureTypeLicenseNotFound = "9610" FailureTypeTemporarilyUnavailable = "2059" CustomerMessageBadLogin = "MZFinance.BadLogin.Configurator_message" CustomerMessageAccountDisabled = "Your account is disabled." CustomerMessageSubscriptionRequired = "Subscription Required" iTunesAPIDomain = "itunes.apple.com" iTunesAPIPathSearch = "/search" iTunesAPIPathLookup = "/lookup" PrivateInitDomain = "init." + iTunesAPIDomain PrivateInitPath = "/bag.xml" PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" HTTPHeaderStoreFront = "X-Set-Apple-Store-Front" HTTPHeaderPod = "pod" PricingParameterAppStore = "STDQ" PricingParameterAppleArcade = "GAME" ) ================================================ FILE: pkg/appstore/error.go ================================================ package appstore type Error struct { Metadata interface{} underlyingError error } func (t Error) Error() string { return t.underlyingError.Error() } func NewErrorWithMetadata(err error, metadata interface{}) *Error { return &Error{ underlyingError: err, Metadata: metadata, } } ================================================ FILE: pkg/appstore/storefront.go ================================================ package appstore import ( "fmt" "strings" ) func countryCodeFromStoreFront(storeFront string) (string, error) { for key, val := range storeFronts { parts := strings.Split(storeFront, "-") if len(parts) >= 1 && parts[0] == val { return key, nil } } return "", fmt.Errorf("country code mapping for store front (%s) was not found", storeFront) } var storeFronts = map[string]string{ "AE": "143481", "AG": "143540", "AI": "143538", "AL": "143575", "AM": "143524", "AO": "143564", "AR": "143505", "AT": "143445", "AU": "143460", "AZ": "143568", "BB": "143541", "BD": "143490", "BE": "143446", "BG": "143526", "BH": "143559", "BM": "143542", "BN": "143560", "BO": "143556", "BR": "143503", "BS": "143539", "BW": "143525", "BY": "143565", "BZ": "143555", "CA": "143455", "CH": "143459", "CI": "143527", "CL": "143483", "CN": "143465", "CO": "143501", "CR": "143495", "CY": "143557", "CZ": "143489", "DE": "143443", "DK": "143458", "DM": "143545", "DO": "143508", "DZ": "143563", "EC": "143509", "EE": "143518", "EG": "143516", "ES": "143454", "FI": "143447", "FR": "143442", "GB": "143444", "GD": "143546", "GE": "143615", "GH": "143573", "GR": "143448", "GT": "143504", "GY": "143553", "HK": "143463", "HN": "143510", "HR": "143494", "HU": "143482", "ID": "143476", "IE": "143449", "IL": "143491", "IN": "143467", "IS": "143558", "IT": "143450", "IQ": "143617", "JM": "143511", "JO": "143528", "JP": "143462", "KE": "143529", "KN": "143548", "KR": "143466", "KW": "143493", "KY": "143544", "KZ": "143517", "LB": "143497", "LC": "143549", "LI": "143522", "LK": "143486", "LT": "143520", "LU": "143451", "LV": "143519", "MD": "143523", "MG": "143531", "MK": "143530", "ML": "143532", "MN": "143592", "MO": "143515", "MS": "143547", "MT": "143521", "MU": "143533", "MV": "143488", "MX": "143468", "MY": "143473", "NE": "143534", "NG": "143561", "NI": "143512", "NL": "143452", "NO": "143457", "NP": "143484", "NZ": "143461", "OM": "143562", "PA": "143485", "PE": "143507", "PH": "143474", "PK": "143477", "PL": "143478", "PT": "143453", "PY": "143513", "QA": "143498", "RO": "143487", "RS": "143500", "RU": "143469", "SA": "143479", "SE": "143456", "SG": "143464", "SI": "143499", "SK": "143496", "SN": "143535", "SR": "143554", "SV": "143506", "TC": "143552", "TH": "143475", "TN": "143536", "TR": "143480", "TT": "143551", "TW": "143470", "TZ": "143572", "UA": "143492", "UG": "143537", "US": "143441", "UY": "143514", "UZ": "143566", "VC": "143550", "VE": "143502", "VG": "143543", "VN": "143471", "YE": "143571", "ZA": "143472", } ================================================ FILE: pkg/http/client.go ================================================ package http import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "strings" "howett.net/plist" ) const ( appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" ) var ( documentXMLPattern = regexp.MustCompile(`(?is)]*>(.*)`) plistXMLPattern = regexp.MustCompile(`(?is)]*>.*?`) dictXMLPattern = regexp.MustCompile(`(?is)]*>.*`) ) //go:generate go run go.uber.org/mock/mockgen -source=client.go -destination=client_mock.go -package=http type Client[R interface{}] interface { Send(request Request) (Result[R], error) Do(req *http.Request) (*http.Response, error) NewRequest(method, url string, body io.Reader) (*http.Request, error) } type client[R interface{}] struct { internalClient http.Client cookieJar CookieJar } type Args struct { CookieJar CookieJar } type AddHeaderTransport struct { T http.RoundTripper } func (t *AddHeaderTransport) RoundTrip(req *http.Request) (*http.Response, error) { if req.Header.Get("User-Agent") == "" { req.Header.Set("User-Agent", DefaultUserAgent) } res, err := t.T.RoundTrip(req) if err != nil { return nil, fmt.Errorf("failed to make round trip: %w", err) } return res, nil } func NewClient[R interface{}](args Args) Client[R] { return &client[R]{ internalClient: http.Client{ Timeout: 0, Jar: args.CookieJar, CheckRedirect: func(req *http.Request, via []*http.Request) error { if req.Referer() == appStoreAuthURL { return http.ErrUseLastResponse } return nil }, Transport: &AddHeaderTransport{http.DefaultTransport}, }, cookieJar: args.CookieJar, } } func (c *client[R]) Send(req Request) (Result[R], error) { var ( data []byte err error ) if req.Payload != nil { data, err = req.Payload.data() if err != nil { return Result[R]{}, fmt.Errorf("failed to get payload data: %w", err) } } request, err := http.NewRequest(req.Method, req.URL, bytes.NewReader(data)) if err != nil { return Result[R]{}, fmt.Errorf("failed to create request: %w", err) } for key, val := range req.Headers { request.Header.Set(key, val) } res, err := c.internalClient.Do(request) if err != nil { return Result[R]{}, fmt.Errorf("request failed: %w", err) } defer res.Body.Close() err = c.cookieJar.Save() if err != nil { return Result[R]{}, fmt.Errorf("failed to save cookies: %w", err) } if req.ResponseFormat == ResponseFormatJSON { return c.handleJSONResponse(res) } if req.ResponseFormat == ResponseFormatXML { return c.handleXMLResponse(res) } return Result[R]{}, fmt.Errorf("content type is not supported (%s)", req.ResponseFormat) } func (c *client[R]) Do(req *http.Request) (*http.Response, error) { res, err := c.internalClient.Do(req) if err != nil { return nil, fmt.Errorf("received error: %w", err) } return res, nil } func (*client[R]) NewRequest(method, url string, body io.Reader) (*http.Request, error) { req, err := http.NewRequest(method, url, body) if err != nil { return nil, fmt.Errorf("failed to create request: %w", err) } return req, nil } func (c *client[R]) handleJSONResponse(res *http.Response) (Result[R], error) { body, err := io.ReadAll(res.Body) if err != nil { return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } var data R err = json.Unmarshal(body, &data) if err != nil { return Result[R]{}, fmt.Errorf("failed to unmarshal json: %w", err) } return Result[R]{ StatusCode: res.StatusCode, Data: data, }, nil } func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { body, err := io.ReadAll(res.Body) if err != nil { return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } var data R normalizedBody := normalizeXMLPlistBody(body) _, err = plist.Unmarshal(normalizedBody, &data) if err != nil { return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err) } headers := map[string]string{} for key, val := range res.Header { headers[key] = strings.Join(val, "; ") } return Result[R]{ StatusCode: res.StatusCode, Headers: headers, Data: data, }, nil } func normalizeXMLPlistBody(body []byte) []byte { normalized := bytes.TrimSpace(body) if len(normalized) == 0 { return normalized } if documentBody := extractDocumentInnerBody(normalized); len(documentBody) > 0 { normalized = documentBody } if embeddedPlist := extractEmbeddedPlist(normalized); len(embeddedPlist) > 0 { normalized = embeddedPlist } if dictBody := extractEmbeddedDict(normalized); len(dictBody) > 0 { return dictBody } if bytes.Contains(normalized, []byte("")) { return []byte("" + string(normalized) + "") } return normalized } func extractEmbeddedPlist(body []byte) []byte { plistMatch := plistXMLPattern.Find(body) if len(plistMatch) == 0 { return nil } return bytes.TrimSpace(plistMatch) } func extractEmbeddedDict(body []byte) []byte { dictMatch := dictXMLPattern.Find(body) if len(dictMatch) == 0 { return nil } return bytes.TrimSpace(dictMatch) } func extractDocumentInnerBody(body []byte) []byte { documentMatch := documentXMLPattern.FindSubmatch(body) if len(documentMatch) < 2 { return nil } documentBody := bytes.TrimSpace(documentMatch[1]) if len(documentBody) == 0 { return nil } return documentBody } ================================================ FILE: pkg/http/client_test.go ================================================ package http import ( "errors" "net/http" "net/http/httptest" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("Client", Ordered, func() { type jsonResult struct { Foo string `json:"foo"` } type xmlResult struct { Foo string `plist:"foo"` } var ( ctrl *gomock.Controller srv *httptest.Server mockHandler func(w http.ResponseWriter, r *http.Request) mockCookieJar *MockCookieJar ) BeforeAll(func() { ctrl = gomock.NewController(GinkgoT()) mockCookieJar = NewMockCookieJar(ctrl) mockHandler = func(w http.ResponseWriter, r *http.Request) {} srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { mockHandler(w, r) })) }) BeforeEach(func() { mockCookieJar.EXPECT(). Cookies(gomock.Any()). Return(nil). MaxTimes(1) }) It("returns request", func() { sut := NewClient[xmlResult](Args{}) req, err := sut.NewRequest("GET", srv.URL, nil) Expect(err).ToNot(HaveOccurred()) Expect(req).ToNot(BeNil()) }) It("returns response", func() { mockHandler = func(_w http.ResponseWriter, r *http.Request) { defer GinkgoRecover() Expect(r.Header.Get("User-Agent")).To(Equal(DefaultUserAgent)) } sut := NewClient[xmlResult](Args{}) req, err := sut.NewRequest("GET", srv.URL, nil) Expect(err).ToNot(HaveOccurred()) Expect(req).ToNot(BeNil()) res, err := sut.Do(req) Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeNil()) }) When("payload decodes successfully", func() { When("cookie jar fails to save", func() { BeforeEach(func() { mockCookieJar.EXPECT(). Save(). Return(errors.New("")) }) It("returns error", func() { sut := NewClient[jsonResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ URL: srv.URL, Method: MethodGET, }) Expect(err).To(HaveOccurred()) }) }) When("cookie jar saves new cookies", func() { BeforeEach(func() { mockCookieJar.EXPECT(). Save(). Return(nil) }) It("decodes JSON response", func() { mockHandler = func(w http.ResponseWriter, _r *http.Request) { w.Header().Add("Content-Type", "application/json") _, err := w.Write([]byte("{\"foo\":\"bar\"}")) Expect(err).ToNot(HaveOccurred()) } sut := NewClient[jsonResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ URL: srv.URL, Method: MethodGET, ResponseFormat: ResponseFormatJSON, Headers: map[string]string{ "foo": "bar", }, Payload: &URLPayload{ Content: map[string]interface{}{ "data": "test", }, }, }) Expect(err).ToNot(HaveOccurred()) Expect(res.Data.Foo).To(Equal("bar")) }) It("decodes XML response", func() { mockHandler = func(w http.ResponseWriter, _r *http.Request) { w.Header().Add("Content-Type", "application/xml") _, err := w.Write([]byte("foobar")) Expect(err).ToNot(HaveOccurred()) } sut := NewClient[xmlResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ URL: srv.URL, Method: MethodPOST, ResponseFormat: ResponseFormatXML, }) Expect(err).ToNot(HaveOccurred()) Expect(res.Data.Foo).To(Equal("bar")) }) It("decodes XML response wrapped in an Apple Document envelope", func() { mockHandler = func(w http.ResponseWriter, _r *http.Request) { w.Header().Add("Content-Type", "application/xml") _, err := w.Write([]byte("\nfoobar")) Expect(err).ToNot(HaveOccurred()) } sut := NewClient[xmlResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ URL: srv.URL, Method: MethodPOST, ResponseFormat: ResponseFormatXML, }) Expect(err).ToNot(HaveOccurred()) Expect(res.Data.Foo).To(Equal("bar")) }) It("decodes XML response wrapped in Document/Protocol/plist with nested dict", func() { mockHandler = func(w http.ResponseWriter, _r *http.Request) { w.Header().Add("Content-Type", "application/xml") _, err := w.Write([]byte("\nnestedabfoobar")) Expect(err).ToNot(HaveOccurred()) } sut := NewClient[xmlResult](Args{ CookieJar: mockCookieJar, }) res, err := sut.Send(Request{ URL: srv.URL, Method: MethodPOST, ResponseFormat: ResponseFormatXML, }) Expect(err).ToNot(HaveOccurred()) Expect(res.Data.Foo).To(Equal("bar")) }) It("returns error when content type is not supported", func() { mockHandler = func(w http.ResponseWriter, _r *http.Request) { w.Header().Add("Content-Type", "application/xyz") } sut := NewClient[xmlResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ URL: srv.URL, Method: MethodPOST, ResponseFormat: "random", }) Expect(err).To(HaveOccurred()) }) }) }) When("payload fails to decode", func() { It("returns error", func() { sut := NewClient[xmlResult](Args{ CookieJar: mockCookieJar, }) _, err := sut.Send(Request{ URL: srv.URL, Method: MethodPOST, ResponseFormat: ResponseFormatXML, Payload: &URLPayload{ Content: map[string]interface{}{ "data": func() {}, }, }, }) Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/http/constants.go ================================================ package http type ResponseFormat string const ( ResponseFormatJSON ResponseFormat = "json" ResponseFormatXML ResponseFormat = "xml" ) const ( DefaultUserAgent = "Configurator/2.17 (Macintosh; OS X 15.2; 24C5089c) AppleWebKit/0620.1.16.11.6" ) ================================================ FILE: pkg/http/cookiejar.go ================================================ package http import "net/http" //go:generate go run go.uber.org/mock/mockgen -source=cookiejar.go -destination=cookiejar_mock.go -package=http type CookieJar interface { http.CookieJar Save() error } ================================================ FILE: pkg/http/http_test.go ================================================ package http import ( "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestHTTP(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "HTTP Suite") } ================================================ FILE: pkg/http/method.go ================================================ package http const ( MethodGET = "GET" MethodPOST = "POST" ) ================================================ FILE: pkg/http/payload.go ================================================ package http import ( "bytes" "fmt" "net/url" "strconv" "howett.net/plist" ) type Payload interface { data() ([]byte, error) } type XMLPayload struct { Content map[string]interface{} } type URLPayload struct { Content map[string]interface{} } func (p *XMLPayload) data() ([]byte, error) { buffer := new(bytes.Buffer) err := plist.NewEncoder(buffer).Encode(p.Content) if err != nil { return nil, fmt.Errorf("failed to encode plist: %w", err) } return buffer.Bytes(), nil } func (p *URLPayload) data() ([]byte, error) { params := url.Values{} for key, val := range p.Content { switch t := val.(type) { case string: params.Add(key, val.(string)) case int: params.Add(key, strconv.Itoa(val.(int))) default: return nil, fmt.Errorf("value type is not supported (%s)", t) } } return []byte(params.Encode()), nil } ================================================ FILE: pkg/http/payload_test.go ================================================ package http import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Payload", func() { var sut Payload Context("URL Payload", func() { It("returns encoded URL data", func() { sut = &URLPayload{ Content: map[string]interface{}{ "foo": "bar", "num": 3, }, } data, err := sut.data() Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal([]byte("foo=bar&num=3"))) }) It("returns error if URL data is invalid", func() { sut = &URLPayload{ Content: map[string]interface{}{ "foo": func() {}, }, } data, err := sut.data() Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) Context("XML Payload", func() { It("returns encoded XML data", func() { sut = &XMLPayload{ Content: map[string]interface{}{ "foo": "bar", "lorem": "ipsum", }, } data, err := sut.data() Expect(err).ToNot(HaveOccurred()) Expect(data).To(ContainSubstring("foobarloremipsum")) }) It("returns error if XML data is invalid", func() { sut = &XMLPayload{ Content: map[string]interface{}{ "foo": func() {}, }, } data, err := sut.data() Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) }) ================================================ FILE: pkg/http/request.go ================================================ package http type Request struct { Method string URL string Headers map[string]string Payload Payload ResponseFormat ResponseFormat } ================================================ FILE: pkg/http/result.go ================================================ package http import ( "errors" "strings" ) var ( ErrHeaderNotFound = errors.New("header not found") ) type Result[R interface{}] struct { StatusCode int Headers map[string]string Data R } func (c *Result[R]) GetHeader(key string) (string, error) { key = strings.ToLower(key) for k, v := range c.Headers { if strings.ToLower(k) == key { return v, nil } } return "", ErrHeaderNotFound } ================================================ FILE: pkg/keychain/keychain.go ================================================ package keychain //go:generate go run go.uber.org/mock/mockgen -source=keychain.go -destination=keychain_mock.go -package keychain type Keychain interface { Get(key string) ([]byte, error) Set(key string, data []byte) error Remove(key string) error } type keychain struct { keyring Keyring } type Args struct { Keyring Keyring } func New(args Args) Keychain { return &keychain{ keyring: args.Keyring, } } ================================================ FILE: pkg/keychain/keychain_get.go ================================================ package keychain import ( "fmt" ) func (k *keychain) Get(key string) ([]byte, error) { item, err := k.keyring.Get(key) if err != nil { return nil, fmt.Errorf("failed to get item: %w", err) } return item.Data, nil } ================================================ FILE: pkg/keychain/keychain_get_test.go ================================================ package keychain import ( "errors" "github.com/99designs/keyring" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("Keychain (Get)", func() { var ( ctrl *gomock.Controller keychain Keychain mockKeyring *MockKeyring ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) keychain = New(Args{ Keyring: mockKeyring, }) }) AfterEach(func() { ctrl.Finish() }) When("keyring returns error", func() { const testKey = "test-key" BeforeEach(func() { mockKeyring.EXPECT(). Get(testKey). Return(keyring.Item{}, errors.New("")) }) It("returns wrapped error", func() { data, err := keychain.Get(testKey) Expect(err).To(HaveOccurred()) Expect(data).To(BeNil()) }) }) When("keyring returns item", func() { const testKey = "test-key" var testData = []byte("test") BeforeEach(func() { mockKeyring.EXPECT(). Get(testKey). Return(keyring.Item{ Data: testData, }, nil) }) It("returns data", func() { data, err := keychain.Get(testKey) Expect(err).ToNot(HaveOccurred()) Expect(data).To(Equal(testData)) }) }) }) ================================================ FILE: pkg/keychain/keychain_remove.go ================================================ package keychain import ( "fmt" ) func (k *keychain) Remove(key string) error { err := k.keyring.Remove(key) if err != nil { return fmt.Errorf("failed to remove item: %w", err) } return nil } ================================================ FILE: pkg/keychain/keychain_remove_test.go ================================================ package keychain import ( "errors" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("Keychain (Remove)", func() { var ( ctrl *gomock.Controller keychain Keychain mockKeyring *MockKeyring ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) keychain = New(Args{ Keyring: mockKeyring, }) }) AfterEach(func() { ctrl.Finish() }) When("keyring returns error", func() { const testKey = "test-key" BeforeEach(func() { mockKeyring.EXPECT(). Remove(testKey). Return(errors.New("")) }) It("returns wrapped error", func() { err := keychain.Remove(testKey) Expect(err).To(HaveOccurred()) }) }) When("keyring does not return error", func() { const testKey = "test-key" BeforeEach(func() { mockKeyring.EXPECT(). Remove(testKey). Return(nil) }) It("returns data", func() { err := keychain.Remove(testKey) Expect(err).ToNot(HaveOccurred()) }) }) }) ================================================ FILE: pkg/keychain/keychain_set.go ================================================ package keychain import ( "fmt" "github.com/99designs/keyring" ) func (k *keychain) Set(key string, data []byte) error { err := k.keyring.Set(keyring.Item{ Key: key, Data: data, }) if err != nil { return fmt.Errorf("failed to set item: %w", err) } return nil } ================================================ FILE: pkg/keychain/keychain_set_test.go ================================================ package keychain import ( "errors" "github.com/99designs/keyring" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) var _ = Describe("Keychain (Set)", func() { var ( ctrl *gomock.Controller keychain Keychain mockKeyring *MockKeyring ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockKeyring = NewMockKeyring(ctrl) keychain = New(Args{ Keyring: mockKeyring, }) }) AfterEach(func() { ctrl.Finish() }) When("keyring returns error", func() { const testKey = "test-key" var testData = []byte("test") BeforeEach(func() { mockKeyring.EXPECT(). Set(keyring.Item{ Key: testKey, Data: testData, }). Return(errors.New("")) }) It("returns wrapped error", func() { err := keychain.Set(testKey, testData) Expect(err).To(HaveOccurred()) }) }) When("keyring does not return error", func() { const testKey = "test-key" var testData = []byte("test") BeforeEach(func() { mockKeyring.EXPECT(). Set(keyring.Item{ Key: testKey, Data: testData, }). Return(nil) }) It("returns nil", func() { err := keychain.Set(testKey, testData) Expect(err).ToNot(HaveOccurred()) }) }) }) ================================================ FILE: pkg/keychain/keychain_test.go ================================================ package keychain import ( "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestKeychain(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Keychain Suite") } ================================================ FILE: pkg/keychain/keyring.go ================================================ package keychain import "github.com/99designs/keyring" //go:generate go run go.uber.org/mock/mockgen -source=keyring.go -destination=keyring_mock.go -package keychain type Keyring interface { Get(key string) (keyring.Item, error) Set(item keyring.Item) error Remove(key string) error } ================================================ FILE: pkg/log/log_test.go ================================================ package log import ( "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestLog(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Log Suite") } ================================================ FILE: pkg/log/logger.go ================================================ package log import ( "io" "github.com/rs/zerolog" "github.com/rs/zerolog/log" "github.com/rs/zerolog/pkgerrors" ) //go:generate go run go.uber.org/mock/mockgen -source=logger.go -destination=logger_mock.go -package log type Logger interface { Verbose() *zerolog.Event Log() *zerolog.Event Error() *zerolog.Event } type logger struct { internalLogger zerolog.Logger verbose bool } type Args struct { Verbose bool Writer io.Writer } func NewLogger(args Args) Logger { internalLogger := log.Logger level := zerolog.InfoLevel if args.Verbose { level = zerolog.DebugLevel zerolog.ErrorStackMarshaler = pkgerrors.MarshalStack } internalLogger = internalLogger.Output(args.Writer).Level(level) return &logger{ verbose: args.Verbose, internalLogger: internalLogger, } } func (l *logger) Log() *zerolog.Event { return l.internalLogger.Info() } func (l *logger) Verbose() *zerolog.Event { if !l.verbose { return nil } return l.internalLogger.Debug() } func (l *logger) Error() *zerolog.Event { return l.internalLogger.Error() } ================================================ FILE: pkg/log/logger_test.go ================================================ package log import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/rs/zerolog" "go.uber.org/mock/gomock" ) var _ = Describe("Logger", func() { var ( ctrl *gomock.Controller mockWriter *MockWriter logger Logger ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockWriter = NewMockWriter(ctrl) }) Context("Verbose logger", func() { BeforeEach(func() { logger = NewLogger(Args{ Verbose: true, Writer: mockWriter, }) }) When("logging with verbose level", func() { It("writes output", func() { mockWriter.EXPECT(). WriteLevel(zerolog.DebugLevel, gomock.Any()). Do(func(level zerolog.Level, p []byte) { Expect(p).To(ContainSubstring("\"message\":\"verbose\"")) }). Return(0, nil) logger.Verbose().Msg("verbose") }) }) }) Context("Non-verbose logger", func() { BeforeEach(func() { logger = NewLogger(Args{ Verbose: false, Writer: mockWriter, }) }) When("logging messsage", func() { It("writes output", func() { mockWriter.EXPECT(). WriteLevel(zerolog.InfoLevel, gomock.Any()). Do(func(level zerolog.Level, p []byte) { Expect(p).To(ContainSubstring("\"message\":\"info\"")) }). Return(0, nil) logger.Log().Msg("info") }) }) When("logging error", func() { It("writes output", func() { mockWriter.EXPECT(). WriteLevel(zerolog.ErrorLevel, gomock.Any()). Do(func(level zerolog.Level, p []byte) { Expect(p).To(ContainSubstring("\"message\":\"error\"")) }). Return(0, nil) logger.Error().Msg("error") }) }) When("logging with verbose level", func() { It("returns nil", func() { res := logger.Verbose() Expect(res).To(BeNil()) }) }) }) }) ================================================ FILE: pkg/log/writer.go ================================================ package log import ( "fmt" "io" "os" "github.com/rs/zerolog" ) //go:generate go run go.uber.org/mock/mockgen -source=writer.go -destination=writer_mock.go -package log type Writer interface { Write(p []byte) (n int, err error) WriteLevel(level zerolog.Level, p []byte) (n int, err error) } type writer struct { stdOutWriter io.Writer stdErrWriter io.Writer } func NewWriter() Writer { return &writer{ stdOutWriter: zerolog.ConsoleWriter{Out: os.Stdout}, stdErrWriter: zerolog.ConsoleWriter{Out: os.Stderr}, } } func (l *writer) Write(p []byte) (int, error) { n, err := l.stdOutWriter.Write(p) if err != nil { return 0, fmt.Errorf("failed to write data: %w", err) } return n, nil } func (l *writer) WriteLevel(level zerolog.Level, p []byte) (int, error) { switch level { case zerolog.DebugLevel, zerolog.InfoLevel, zerolog.WarnLevel: n, err := l.stdOutWriter.Write(p) if err != nil { return 0, fmt.Errorf("failed to write data: %w", err) } return n, nil case zerolog.ErrorLevel: n, err := l.stdErrWriter.Write(p) if err != nil { return 0, fmt.Errorf("failed to write data: %w", err) } return n, nil default: return len(p), nil } } ================================================ FILE: pkg/log/writer_test.go ================================================ package log import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "github.com/rs/zerolog" "go.uber.org/mock/gomock" ) var _ = Describe("Writer", func() { var ( ctrl *gomock.Controller mockStdoutWriter *MockWriter mockStderrWriter *MockWriter sut *writer ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockStdoutWriter = NewMockWriter(ctrl) mockStderrWriter = NewMockWriter(ctrl) sut = &writer{ stdOutWriter: mockStdoutWriter, stdErrWriter: mockStderrWriter, } }) It("returns valid writer", func() { out := NewWriter() Expect(out).ToNot(BeNil()) }) When("writing logs", func() { It("writes debug logs to stdout", func() { mockStdoutWriter.EXPECT().Write([]byte("debug")).Return(0, nil) _, err := sut.WriteLevel(zerolog.DebugLevel, []byte("debug")) Expect(err).ToNot(HaveOccurred()) }) It("writes info logs to stdout", func() { mockStdoutWriter.EXPECT().Write([]byte("info")).Return(0, nil).Times(2) _, err := sut.Write([]byte("info")) Expect(err).ToNot(HaveOccurred()) _, err = sut.WriteLevel(zerolog.InfoLevel, []byte("info")) Expect(err).ToNot(HaveOccurred()) }) It("writes warn logs to stdout", func() { mockStdoutWriter.EXPECT().Write([]byte("warning")).Return(0, nil) _, err := sut.WriteLevel(zerolog.WarnLevel, []byte("warning")) Expect(err).ToNot(HaveOccurred()) }) It("writes error logs to stderr", func() { mockStderrWriter.EXPECT().Write([]byte("error")).Return(0, nil) _, err := sut.WriteLevel(zerolog.ErrorLevel, []byte("error")) Expect(err).ToNot(HaveOccurred()) }) }) When("log level is not supported", func() { It("returns the length of the passed log", func() { length, err := sut.WriteLevel(zerolog.PanicLevel, []byte("panic")) Expect(err).ToNot(HaveOccurred()) Expect(length).To(Equal(5)) }) }) }) ================================================ FILE: pkg/util/machine/machine.go ================================================ package machine import ( "fmt" "net" "path/filepath" "runtime" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" "golang.org/x/term" ) //go:generate go run go.uber.org/mock/mockgen -source=machine.go -destination=machine_mock.go -package machine type Machine interface { MacAddress() (string, error) HomeDirectory() string ReadPassword(fd int) ([]byte, error) } type machine struct { os operatingsystem.OperatingSystem } type Args struct { OS operatingsystem.OperatingSystem } func New(args Args) Machine { return &machine{ os: args.OS, } } func (*machine) MacAddress() (string, error) { interfaces, err := net.Interfaces() if err != nil { return "", fmt.Errorf("failed to get network interfaces: %w", err) } if len(interfaces) == 0 { return "", fmt.Errorf("could not find network interfaces: %w", err) } for _, netInterface := range interfaces { addr := netInterface.HardwareAddr.String() if addr != "" { return addr, nil } } return "", fmt.Errorf("could not find network interfaces with a valid mac address: %w", err) } func (m *machine) HomeDirectory() string { if runtime.GOOS == "windows" { return filepath.Join(m.os.Getenv("HOMEDRIVE"), m.os.Getenv("HOMEPATH")) } return m.os.Getenv("HOME") } func (*machine) ReadPassword(fd int) ([]byte, error) { data, err := term.ReadPassword(fd) if err != nil { return nil, fmt.Errorf("failed to read password: %w", err) } return data, nil } ================================================ FILE: pkg/util/machine/machine_test.go ================================================ package machine import ( "syscall" "testing" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" ) func TestMachine(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Machine Suite") } var _ = Describe("Machine", func() { var ( ctrl *gomock.Controller machine Machine mockOS *operatingsystem.MockOperatingSystem ) BeforeEach(func() { ctrl = gomock.NewController(GinkgoT()) mockOS = operatingsystem.NewMockOperatingSystem(ctrl) machine = New(Args{ OS: mockOS, }) }) When("OperatingSystem is darwin", func() { BeforeEach(func() { mockOS.EXPECT(). Getenv("HOME"). Return("/home/test") }) It("returns home directory from HOME", func() { dir := machine.HomeDirectory() Expect(dir).To(Equal("/home/test")) }) }) When("machine has network interfaces", func() { It("returns MAC address of the first interface", func() { res, err := machine.MacAddress() Expect(err).ToNot(HaveOccurred()) Expect(res).To(ContainSubstring(":")) }) }) When("reading password from stdout", func() { It("returns error", func() { _, err := machine.ReadPassword(syscall.Stdout) Expect(err).To(HaveOccurred()) }) }) }) ================================================ FILE: pkg/util/must.go ================================================ package util // Must is a helper that wraps a call to a function returning (T, error) and panics if the error is non-nil. func Must[T any](val T, err error) T { if err != nil { panic(err.Error()) } return val } ================================================ FILE: pkg/util/must_test.go ================================================ package util import ( "errors" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Must", func() { It("returns current value", func() { res := Must("value", nil) Expect(res).To(Equal("value")) }) It("panics", func() { defer func() { r := recover() Expect(r).To(Equal("test")) }() _ = Must("value", errors.New("test")) }) }) ================================================ FILE: pkg/util/operatingsystem/operatingsystem.go ================================================ package operatingsystem import ( "os" ) //go:generate go run go.uber.org/mock/mockgen -source=operatingsystem.go -destination=operatingsystem_mock.go -package operatingsystem type OperatingSystem interface { Getenv(key string) string Stat(name string) (os.FileInfo, error) Getwd() (string, error) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) Remove(name string) error IsNotExist(err error) bool MkdirAll(path string, perm os.FileMode) error Rename(oldPath, newPath string) error } type operatingSystem struct{} func New() OperatingSystem { return &operatingSystem{} } func (operatingSystem) Getenv(key string) string { return os.Getenv(key) } // nolint:wrapcheck func (operatingSystem) Stat(name string) (os.FileInfo, error) { return os.Stat(name) } // nolint:wrapcheck func (operatingSystem) Getwd() (string, error) { return os.Getwd() } // nolint:wrapcheck func (operatingSystem) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { return os.OpenFile(name, flag, perm) } // nolint:wrapcheck func (operatingSystem) Remove(name string) error { return os.Remove(name) } // nolint:wrapcheck func (operatingSystem) IsNotExist(err error) bool { return os.IsNotExist(err) } // nolint:wrapcheck func (operatingSystem) MkdirAll(path string, perm os.FileMode) error { return os.MkdirAll(path, perm) } // nolint:wrapcheck func (operatingSystem) Rename(oldPath, newPath string) error { return os.Rename(oldPath, newPath) } ================================================ FILE: pkg/util/operatingsystem/operatingsystem_test.go ================================================ package operatingsystem import ( "fmt" "io/fs" "math/rand" "os" "path" "testing" "time" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestOS(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "OperatingSystem Suite") } var _ = Describe("OperatingSystem", func() { var sut OperatingSystem BeforeEach(func() { sut = New() }) When("env var is set", func() { BeforeEach(func() { err := os.Setenv("TEST", "true") Expect(err).ToNot(HaveOccurred()) }) It("returns env var", func() { res := sut.Getenv("TEST") Expect(res).To(Equal("true")) }) }) When("file exists", func() { var file *os.File BeforeEach(func() { var err error file, err = os.CreateTemp("", "test_file") Expect(err).ToNot(HaveOccurred()) }) AfterEach(func() { err := file.Close() Expect(err).ToNot(HaveOccurred()) }) It("returns file info", func() { res, err := sut.Stat(file.Name()) Expect(err).ToNot(HaveOccurred()) Expect(res.Name()).To(Equal(path.Base(file.Name()))) }) It("opens file", func() { res, err := sut.OpenFile(file.Name(), os.O_WRONLY, 0644) Expect(err).ToNot(HaveOccurred()) Expect(res.Name()).To(Equal(file.Name())) }) It("removes file", func() { err := sut.Remove(file.Name()) Expect(err).ToNot(HaveOccurred()) _, err = sut.Stat(file.Name()) Expect(os.IsNotExist(err)).To(BeTrue()) }) It("renames file", func() { r := rand.New(rand.NewSource(time.Now().UnixNano())) newPath := fmt.Sprintf("%s/%d", os.TempDir(), r.Intn(100)) err := sut.Rename(file.Name(), newPath) defer func() { _ = sut.Remove(newPath) }() Expect(err).ToNot(HaveOccurred()) }) }) When("running", func() { It("returns current working directory", func() { res, err := sut.Getwd() Expect(err).ToNot(HaveOccurred()) Expect(res).ToNot(BeNil()) }) }) When("error is 'ErrNotExist'", func() { It("returns true", func() { res := sut.IsNotExist(fs.ErrNotExist) Expect(res).To(BeTrue()) }) }) When("directory does not exist", func() { It("creates directory", func() { err := sut.MkdirAll(os.TempDir(), 0664) Expect(err).ToNot(HaveOccurred()) }) }) }) ================================================ FILE: pkg/util/string.go ================================================ package util func IfEmpty(value, fallback string) string { if value == "" { return fallback } return value } ================================================ FILE: pkg/util/string_test.go ================================================ package util import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("String", func() { It("returns current value", func() { res := IfEmpty("current", "fallback") Expect(res).To(Equal("current")) }) It("returns fallback value", func() { res := IfEmpty("", "fallback") Expect(res).To(Equal("fallback")) }) }) ================================================ FILE: pkg/util/util_test.go ================================================ package util import ( "testing" . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) func TestUtil(t *testing.T) { RegisterFailHandler(Fail) RunSpecs(t, "Util Suite") } ================================================ FILE: pkg/util/zip.go ================================================ package util import "errors" type Pair[T, U any] struct { First T Second U } func Zip[T, U any](ts []T, us []U) ([]Pair[T, U], error) { if len(ts) != len(us) { return nil, errors.New("slices have different lengths") } pairs := make([]Pair[T, U], len(ts)) for i := 0; i < len(ts); i++ { pairs[i] = Pair[T, U]{ First: ts[i], Second: us[i], } } return pairs, nil } ================================================ FILE: pkg/util/zip_test.go ================================================ package util import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" ) var _ = Describe("Zip", func() { When("slices have different lengths", func() { It("returns error", func() { _, err := Zip([]string{}, []string{"test"}) Expect(err).To(HaveOccurred()) }) }) When("slices have different lengths", func() { It("returns zipped slices", func() { res, err := Zip([]string{ "lslice1", "lslice2", }, []string{ "rslice1", "rslice2", }) Expect(err).ToNot(HaveOccurred()) Expect(res[0].First).To(Equal("lslice1")) Expect(res[0].Second).To(Equal("rslice1")) Expect(res[1].First).To(Equal("lslice2")) Expect(res[1].Second).To(Equal("rslice2")) }) }) }) ================================================ FILE: tools/sha256sum.sh ================================================ #!/bin/sh -e if which sha256sum >/dev/null 2>&1; then sha256sum "$1" | awk '{ print $1 }' else shasum -a256 "$1" | awk '{ print $1 }' fi ================================================ FILE: tools.go ================================================ //go:build tools package main import ( _ "go.uber.org/mock/mockgen" )