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