From 13afbaf0263972faa3fbcaabc880e4b8f4371a55 Mon Sep 17 00:00:00 2001 From: pdx15 <301492+pdx15@users.noreply.github.com> Date: Tue, 15 Sep 2026 20:02:35 +0000 Subject: [PATCH 01/18] Apply upstream 73fabcc..5c522b6 with fork adaptations Brings the first 11 upstream commits (30b2909..5c522b6 of majd/ipatool) into the fork in one reconstructed commit after a sandbox history reset: - 73fabcc, abdb590, e6acb9c, b8bac29, 843f5c6, a814a71: download/resume, zip framing/headers, state directory, ranged responses - 24f93d3: .github/FUNDING.yml (fork README kept) - d1845ba: authentication transport isolation (DisableKeepAlives for auth client), 429/Retry-After handling, auth retry with backoff in login flow, stage-aware error messages; fork MZFinance pod redirect flow preserved (parseLoginResponse stays 2-arg; no endpoint validation) - ba180d7: visionos/macos platform support, owned-apps platform filtering, multi-storefront (34/13) fetch + mergeOwnedApps, --platform CLI flag (fork All flag and SignAction-based signing preserved) - 5c522b6: macOS external version lookup via Mac product page (storefrontClient added to appstore), wired into Download() for PlatformMacOS; fork download flow (fetchDownloadItem/redownload) kept Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/FUNDING.yml | 1 + cmd/common.go | 44 +- cmd/purchases.go | 20 +- cmd/purchases_test.go | 88 +++ cmd/state_directory.go | 61 ++ cmd/state_directory_test.go | 182 ++++++ pkg/appstore/app.go | 12 +- pkg/appstore/app_test.go | 16 + pkg/appstore/appstore.go | 62 +- pkg/appstore/appstore_download.go | 96 ++- pkg/appstore/appstore_download_artwork.go | 46 ++ .../appstore_download_artwork_test.go | 112 ++++ pkg/appstore/appstore_download_range.go | 47 ++ pkg/appstore/appstore_download_resume_test.go | 130 ++++ pkg/appstore/appstore_download_test.go | 116 +++- pkg/appstore/appstore_login.go | 125 +++- pkg/appstore/appstore_login_test.go | 2 +- pkg/appstore/appstore_macos_version_lookup.go | 131 ++++ .../appstore_macos_version_lookup_test.go | 54 ++ pkg/appstore/appstore_owned_apps.go | 137 +++- pkg/appstore/appstore_owned_apps_test.go | 584 ++++++++++++++---- pkg/appstore/appstore_replicate_sinf.go | 69 ++- pkg/appstore/appstore_zip_framing_test.go | 124 ++++ pkg/appstore/appstore_zip_headers.go | 289 +++++++++ pkg/appstore/appstore_zip_headers_test.go | 209 +++++++ pkg/appstore/authentication_retry_test.go | 175 ++++++ pkg/appstore/constants.go | 1 + pkg/appstore/platform.go | 37 +- pkg/http/authentication_test.go | 164 +++++ pkg/http/client.go | 62 +- 30 files changed, 2931 insertions(+), 265 deletions(-) create mode 100644 .github/FUNDING.yml create mode 100644 cmd/purchases_test.go create mode 100644 cmd/state_directory.go create mode 100644 cmd/state_directory_test.go create mode 100644 pkg/appstore/appstore_download_artwork.go create mode 100644 pkg/appstore/appstore_download_artwork_test.go create mode 100644 pkg/appstore/appstore_download_range.go create mode 100644 pkg/appstore/appstore_download_resume_test.go create mode 100644 pkg/appstore/appstore_macos_version_lookup.go create mode 100644 pkg/appstore/appstore_macos_version_lookup_test.go create mode 100644 pkg/appstore/appstore_zip_framing_test.go create mode 100644 pkg/appstore/appstore_zip_headers.go create mode 100644 pkg/appstore/appstore_zip_headers_test.go create mode 100644 pkg/appstore/authentication_retry_test.go create mode 100644 pkg/http/authentication_test.go diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 0000000..4785480 --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1 @@ +github: majd diff --git a/cmd/common.go b/cmd/common.go index 62ef605..490139d 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -57,8 +57,8 @@ func newLogger(format OutputFormat, verbose bool) log.Logger { // file is corrupt (e.g. left over from an interrupted write or an // incompatible cookie format), the broken file is moved aside and a fresh // jar is created instead of crashing the whole program on startup. -func newCookieJar(machine machine.Machine) http.CookieJar { - filename := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName, CookieJarFileName) +func newCookieJar(stateDirectory string) http.CookieJar { + filename := filepath.Join(stateDirectory, CookieJarFileName) jar, err := cookiejar.New(&cookiejar.Options{Filename: filename}) if err == nil { @@ -120,22 +120,22 @@ func (envSessionKeychain) Remove(_ string) error { } // keychainPassphraseFile is the name of the file that stores the auto-generated -// keychain passphrase. Keeping it in the ipatool config directory means the +// keychain passphrase. Keeping it in the ipatool state directory means the // session token stays decryptable across runs without prompting the user. const keychainPassphraseFile = "keychain-passphrase" // resolveKeychainPassphrase returns the passphrase used to encrypt the local // keychain file. An explicitly provided --keychain-passphrase wins; otherwise a // random passphrase is generated on first use and persisted in the ipatool -// config directory, so the user is never prompted for a separate local +// state directory, so the user is never prompted for a separate local // password. (The OS keyring backends, when present, are used in preference to // the file backend anyway.) -func resolveKeychainPassphrase(machine machine.Machine) (string, error) { +func resolveKeychainPassphrase(stateDirectory string) (string, error) { if keychainPassphrase != "" { return keychainPassphrase, nil } - dir := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName) + dir := stateDirectory path := filepath.Join(dir, keychainPassphraseFile) if data, err := os.ReadFile(path); err == nil { @@ -150,7 +150,7 @@ func resolveKeychainPassphrase(machine machine.Machine) (string, error) { passphrase := hex.EncodeToString(random) if err := os.MkdirAll(dir, 0o700); err != nil { - return "", fmt.Errorf("failed to create config directory: %w", err) + return "", fmt.Errorf("failed to create state directory: %w", err) } if err := os.WriteFile(path, []byte(passphrase+"\n"), 0o600); err != nil { @@ -161,12 +161,12 @@ func resolveKeychainPassphrase(machine machine.Machine) (string, error) { } // newKeychain returns a new keychain instance. -func newKeychain(machine machine.Machine) keychain.Keychain { +func newKeychain(stateDirectory string) keychain.Keychain { if session := os.Getenv("IPATOOL_SESSION"); session != "" { return envSessionKeychain{data: []byte(session)} } - passphrase, err := resolveKeychainPassphrase(machine) + passphrase, err := resolveKeychainPassphrase(stateDirectory) if err != nil { util.Must("", err) } @@ -178,7 +178,7 @@ func newKeychain(machine machine.Machine) keychain.Keychain { keyring.FileBackend, }, ServiceName: KeychainServiceName, - FileDir: filepath.Join(machine.HomeDirectory(), ConfigDirectoryName), + FileDir: stateDirectory, FilePasswordFunc: func(s string) (string, error) { return passphrase, nil }, @@ -195,31 +195,13 @@ func initWithCommand(cmd *cobra.Command) { dependencies.Logger = newLogger(format, verbose) dependencies.OS = operatingsystem.New() dependencies.Machine = machine.New(machine.Args{OS: dependencies.OS}) - dependencies.CookieJar = newCookieJar(dependencies.Machine) - dependencies.Keychain = newKeychain(dependencies.Machine) + stateDirectory := util.Must(prepareStateDirectory(dependencies.OS, dependencies.Machine.HomeDirectory())) + dependencies.CookieJar = newCookieJar(stateDirectory) + dependencies.Keychain = newKeychain(stateDirectory) dependencies.AppStore = appstore.NewAppStore(appstore.Args{ CookieJar: dependencies.CookieJar, OperatingSystem: dependencies.OS, Keychain: dependencies.Keychain, Machine: dependencies.Machine, }) - - util.Must("", createConfigDirectory(dependencies.OS, dependencies.Machine)) -} - -// createConfigDirectory creates the configuration directory for the CLI tool, if needed. -func createConfigDirectory(os operatingsystem.OperatingSystem, machine machine.Machine) error { - configDirectoryPath := filepath.Join(machine.HomeDirectory(), ConfigDirectoryName) - _, err := os.Stat(configDirectoryPath) - - if err != nil && os.IsNotExist(err) { - err = os.MkdirAll(configDirectoryPath, 0700) - if err != nil { - return fmt.Errorf("failed to create config directory: %w", err) - } - } else if err != nil { - return fmt.Errorf("could not read metadata: %w", err) - } - - return nil } diff --git a/cmd/purchases.go b/cmd/purchases.go index e07104e..c19d871 100644 --- a/cmd/purchases.go +++ b/cmd/purchases.go @@ -18,11 +18,21 @@ func listPurchasesCmd() *cobra.Command { all bool ) + var platformValue string + + var platform appstore.Platform + cmd := &cobra.Command{ Use: "list-purchases", Short: "List apps owned by the authenticated App Store account", Args: cobra.NoArgs, PreRunE: func(cmd *cobra.Command, args []string) error { + var err error + platform, err = appstore.ParsePlatform(platformValue) + if err != nil { + return err + } + if all { return nil } @@ -56,10 +66,11 @@ func listPurchasesCmd() *cobra.Command { } output, err := dependencies.AppStore.OwnedApps(appstore.OwnedAppsInput{ - Account: acc, - Page: page, - Limit: maxResults, - All: all, + Account: acc, + Page: page, + Limit: maxResults, + Platform: platform, + All: all, }) if err != nil { return err @@ -90,6 +101,7 @@ func listPurchasesCmd() *cobra.Command { cmd.Flags().IntVarP(&maxResults, "max-results", "l", appstore.DefaultOwnedAppsLimit, "maximum number of apps to return per page") cmd.Flags().IntVarP(&page, "page", "p", 1, "page of owned apps to return") cmd.Flags().BoolVar(&all, "all", false, "return every owned app in one response (ignores --page and --max-results)") + cmd.Flags().StringVar(&platformValue, "platform", "", "Filter by platform: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos") return cmd } diff --git a/cmd/purchases_test.go b/cmd/purchases_test.go new file mode 100644 index 0000000..9c94d8d --- /dev/null +++ b/cmd/purchases_test.go @@ -0,0 +1,88 @@ +package cmd + +import ( + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("List Purchases command", func() { + DescribeTable("passes the platform filter to owned apps", + func(value string, expected appstore.Platform) { + store := &fakePurchasesAppStore{} + previousDependencies := dependencies + DeferCleanup(func() { dependencies = previousDependencies }) + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + cmd := listPurchasesCmd() + cmd.SetArgs([]string{"--platform", value}) + + Expect(cmd.Execute()).To(Succeed()) + Expect(store.input.Platform).To(Equal(expected)) + }, + Entry("all platforms by default", "", appstore.Platform("")), + Entry("iOS alias", "ios", appstore.PlatformIPhone), + Entry("iPadOS alias", "ipados", appstore.PlatformIPad), + Entry("tvOS alias", "tvos", appstore.PlatformAppleTV), + Entry("visionOS", "visionos", appstore.PlatformVisionOS), + Entry("macOS", "macos", appstore.PlatformMacOS), + ) + + DescribeTable("rejects an invalid platform before accessing the account", func(value string) { + cmd := listPurchasesCmd() + Expect(cmd.Flags().Set("platform", value)).To(Succeed()) + Expect(cmd.PreRunE(cmd, nil)).To(MatchError(ContainSubstring("invalid platform"))) + }, + Entry("invalid value", "invalid"), + Entry("unknown is output only", "unknown"), + Entry("uppercase unknown", "UNKNOWN"), + ) + + It("uses the pagination defaults", func() { + cmd := listPurchasesCmd() + + page, err := cmd.Flags().GetInt("page") + Expect(err).ToNot(HaveOccurred()) + Expect(page).To(Equal(1)) + + maxResults, err := cmd.Flags().GetInt("max-results") + Expect(err).ToNot(HaveOccurred()) + Expect(maxResults).To(Equal(appstore.DefaultOwnedAppsLimit)) + }) + + DescribeTable("rejects invalid pagination", + func(flag, value, errorText string) { + cmd := listPurchasesCmd() + Expect(cmd.Flags().Set(flag, value)).To(Succeed()) + + err := cmd.PreRunE(cmd, nil) + Expect(err).To(MatchError(ContainSubstring(errorText))) + }, + Entry("page below one", "page", "0", "page"), + Entry("max results below one", "max-results", "0", "max results"), + Entry("max results over limit", "max-results", "101", "100"), + ) + + It("is registered on the root command", func() { + cmd, _, err := rootCmd().Find([]string{"list-purchases"}) + + Expect(err).ToNot(HaveOccurred()) + Expect(cmd.Name()).To(Equal("list-purchases")) + }) +}) + +type fakePurchasesAppStore struct { + appstore.AppStore + input appstore.OwnedAppsInput +} + +func (*fakePurchasesAppStore) AccountInfo() (appstore.AccountInfoOutput, error) { + return appstore.AccountInfoOutput{}, nil +} + +func (s *fakePurchasesAppStore) OwnedApps(input appstore.OwnedAppsInput) (appstore.OwnedAppsOutput, error) { + s.input = input + + return appstore.OwnedAppsOutput{}, nil +} diff --git a/cmd/state_directory.go b/cmd/state_directory.go new file mode 100644 index 0000000..c23d3bc --- /dev/null +++ b/cmd/state_directory.go @@ -0,0 +1,61 @@ +package cmd + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" +) + +// prepareStateDirectory resolves and migrates session storage before it is opened. +func prepareStateDirectory(os operatingsystem.OperatingSystem, homeDirectory string) (string, error) { + legacyDirectory := filepath.Join(homeDirectory, ConfigDirectoryName) + stateDirectory := legacyDirectory + + for _, variable := range []string{"XDG_STATE_HOME", "XDG_DATA_HOME"} { + if base := os.Getenv(variable); filepath.IsAbs(base) { + stateDirectory = filepath.Join(base, "ipatool") + + break + } + } + + info, err := os.Stat(legacyDirectory) + if err != nil && !os.IsNotExist(err) { + return "", fmt.Errorf("could not read legacy state directory metadata: %w", err) + } + + if err == nil { + if !info.IsDir() { + return "", fmt.Errorf("legacy state path is not a directory: %s", legacyDirectory) + } + // Never merge sessions or move a directory into itself. Keeping the legacy + // directory also preserves authentication when migration is unavailable. + relative, err := filepath.Rel(legacyDirectory, stateDirectory) + if err == nil && relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) { + return legacyDirectory, nil + } + + if _, err := os.Stat(stateDirectory); err == nil || !os.IsNotExist(err) { + return legacyDirectory, nil + } + + if err := os.MkdirAll(filepath.Dir(stateDirectory), 0700); err != nil { + return legacyDirectory, nil + } + // Rename keeps cookies and encrypted credentials together, with their + // existing permissions. Cross-filesystem moves fall back to legacy storage. + if err := os.Rename(legacyDirectory, stateDirectory); err != nil { + return legacyDirectory, nil + } + + return stateDirectory, nil + } + + if err := os.MkdirAll(stateDirectory, 0700); err != nil { + return "", fmt.Errorf("failed to create state directory: %w", err) + } + + return stateDirectory, nil +} diff --git a/cmd/state_directory_test.go b/cmd/state_directory_test.go new file mode 100644 index 0000000..4e3fca9 --- /dev/null +++ b/cmd/state_directory_test.go @@ -0,0 +1,182 @@ +package cmd + +import ( + "errors" + "net/http" + "net/url" + "os" + "path/filepath" + "time" + + "github.com/byteness/keyring" + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("State directory", func() { + var home, legacy, target string + + BeforeEach(func() { + home = GinkgoT().TempDir() + legacy = filepath.Join(home, ConfigDirectoryName) + target = filepath.Join(home, "state", "ipatool") + GinkgoT().Setenv("XDG_STATE_HOME", filepath.Dir(target)) + GinkgoT().Setenv("XDG_DATA_HOME", "") + }) + + DescribeTable("selects the session directory", func(state, data, want string) { + for variable, value := range map[string]string{"XDG_STATE_HOME": state, "XDG_DATA_HOME": data} { + if value != "" && value != "relative" { + value = filepath.Join(home, value) + } + + GinkgoT().Setenv(variable, value) + } + + got, err := prepareStateDirectory(operatingsystem.New(), home) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(filepath.Join(home, want))) + Expect(got).To(BeADirectory()) + + if got != legacy { + Expect(legacy).ToNot(BeAnExistingFile()) + } + }, + Entry("defaults to legacy storage", "", "", ConfigDirectoryName), + Entry("prefers state storage", "state", "data", "state/ipatool"), + Entry("falls back to data storage", "", "data", "data/ipatool"), + Entry("ignores a relative state path", "relative", "data", "data/ipatool"), + Entry("ignores relative paths", "relative", "relative", ConfigDirectoryName), + ) + + It("preserves authentication after migration and subsequent startup", func() { + Expect(os.MkdirAll(legacy, 0700)).To(Succeed()) + + openKeyring := func(directory string) keyring.Keyring { + ring, err := keyring.Open(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.FileBackend}, + ServiceName: KeychainServiceName, + FileDir: directory, + FilePasswordFunc: keyring.FixedStringPrompt("test-passphrase"), + }) + ExpectWithOffset(1, err).ToNot(HaveOccurred()) + + return ring + } + item := keyring.Item{Key: "account", Data: []byte("existing-session"), Label: KeychainServiceName} + Expect(openKeyring(legacy).Set(item)).To(Succeed()) + + cookieURL, err := url.Parse("https://apps.apple.com/") + Expect(err).ToNot(HaveOccurred()) + + jar := newCookieJar(legacy) + jar.SetCookies(cookieURL, []*http.Cookie{{Name: "session", Value: "existing-cookie", Path: "/", Expires: time.Now().Add(time.Hour)}}) + Expect(jar.Save()).To(Succeed()) + + before, err := os.ReadFile(filepath.Join(legacy, item.Key)) + Expect(err).ToNot(HaveOccurred()) + + for i := 0; i < 2; i++ { + directory, err := prepareStateDirectory(operatingsystem.New(), home) + Expect(err).ToNot(HaveOccurred()) + Expect(directory).To(Equal(target)) + + got, err := openKeyring(directory).Get(item.Key) + Expect(err).ToNot(HaveOccurred()) + Expect(got.Data).To(Equal(item.Data)) + + after, err := os.ReadFile(filepath.Join(directory, item.Key)) + Expect(err).ToNot(HaveOccurred()) + Expect(after).To(Equal(before), "encrypted credentials must remain unchanged") + + cookies := newCookieJar(directory).Cookies(cookieURL) + Expect(cookies).To(HaveLen(1)) + Expect(cookies[0].Name).To(Equal("session")) + Expect(cookies[0].Value).To(Equal("existing-cookie")) + } + + Expect(legacy).ToNot(BeAnExistingFile()) + }) + + DescribeTable("preserves legacy storage when migration fails", func(mkdirErr, renameErr error) { + Expect(os.MkdirAll(legacy, 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(legacy, CookieJarFileName), []byte("session"), 0600)).To(Succeed()) + + got, err := prepareStateDirectory(migrationFailureOS{operatingsystem.New(), mkdirErr, renameErr}, home) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(legacy)) + + data, err := os.ReadFile(filepath.Join(got, CookieJarFileName)) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("session")) + }, + Entry("parent creation denied", os.ErrPermission, nil), + Entry("rename denied", nil, os.ErrPermission), + Entry("cross filesystem", nil, errors.New("cross-device link")), + ) + + It("preserves conflicting sessions and uses legacy storage", func() { + for _, directory := range []string{legacy, target} { + Expect(os.MkdirAll(directory, 0700)).To(Succeed()) + Expect(os.WriteFile(filepath.Join(directory, "account"), []byte(directory), 0600)).To(Succeed()) + } + + got, err := prepareStateDirectory(operatingsystem.New(), home) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(legacy)) + + for _, directory := range []string{legacy, target} { + data, err := os.ReadFile(filepath.Join(directory, "account")) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal(directory)) + } + }) + + DescribeTable("rejects files at directory paths", func(useLegacy bool) { + path := target + if useLegacy { + path = legacy + } + + Expect(os.MkdirAll(filepath.Dir(path), 0700)).To(Succeed()) + Expect(os.WriteFile(path, []byte("existing file"), 0600)).To(Succeed()) + + _, err := prepareStateDirectory(operatingsystem.New(), home) + Expect(err).To(HaveOccurred()) + }, + Entry("legacy", true), + Entry("destination", false), + ) + + It("preserves legacy storage for a nested destination", func() { + GinkgoT().Setenv("XDG_STATE_HOME", filepath.Join(legacy, "state")) + Expect(os.MkdirAll(legacy, 0700)).To(Succeed()) + + got, err := prepareStateDirectory(operatingsystem.New(), home) + Expect(err).ToNot(HaveOccurred()) + Expect(got).To(Equal(legacy)) + + entries, err := os.ReadDir(legacy) + Expect(err).ToNot(HaveOccurred()) + Expect(entries).To(BeEmpty(), "migration must not create directories inside legacy storage") + }) +}) + +type migrationFailureOS struct { + operatingsystem.OperatingSystem + mkdirErr, renameErr error +} + +// nolint:wrapcheck +func (o migrationFailureOS) MkdirAll(path string, mode os.FileMode) error { + if o.mkdirErr != nil { + return o.mkdirErr + } + + return o.OperatingSystem.MkdirAll(path, mode) +} + +func (o migrationFailureOS) Rename(from, to string) error { + return o.renameErr +} diff --git a/pkg/appstore/app.go b/pkg/appstore/app.go index e190623..58fd2fe 100644 --- a/pkg/appstore/app.go +++ b/pkg/appstore/app.go @@ -57,7 +57,8 @@ type App struct { RatingCount int64 `json:"userRatingCount,omitempty"` Genres []string `json:"genres,omitempty"` // PurchaseDate is only populated for apps returned by OwnedApps. - PurchaseDate time.Time `json:"purchaseDate,omitzero"` + PurchaseDate time.Time `json:"purchaseDate,omitzero"` + Platforms []Platform `json:"platforms,omitzero"` } type VersionHistoryInfo struct { @@ -92,4 +93,13 @@ func (a App) MarshalZerologObject(event *zerolog.Event) { if !a.PurchaseDate.IsZero() { event.Time("purchaseDate", a.PurchaseDate) } + + if a.Platforms != nil { + platforms := make([]string, len(a.Platforms)) + for index, platform := range a.Platforms { + platforms[index] = string(platform) + } + + event.Strs("platforms", platforms) + } } diff --git a/pkg/appstore/app_test.go b/pkg/appstore/app_test.go index 1a190c5..a52e7ba 100644 --- a/pkg/appstore/app_test.go +++ b/pkg/appstore/app_test.go @@ -65,6 +65,22 @@ var _ = Describe("App", func() { Expect(out["price"]).To(Equal(float64(0))) }) + DescribeTable("serializes purchase platforms as an array", + func(platforms []Platform, expected string) { + app := App{ID: 42, Platforms: platforms} + data, err := json.Marshal(app) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(ContainSubstring(`"platforms":` + expected)) + + buffer := bytes.NewBuffer(nil) + logger := zerolog.New(buffer) + logger.Log().Object("app", app).Send() + Expect(buffer.String()).To(ContainSubstring(`"platforms":` + expected)) + }, + Entry("multiple platforms", []Platform{PlatformIPhone, PlatformIPad, PlatformMacOS}, `["iphone","ipad","macos"]`), + Entry("unknown platforms", []Platform{PlatformUnknown}, `["unknown"]`), + ) + It("formats ipa name correctly", func() { app := App{ ID: 42, diff --git a/pkg/appstore/appstore.go b/pkg/appstore/appstore.go index bc1d8f6..fcdb4cd 100644 --- a/pkg/appstore/appstore.go +++ b/pkg/appstore/appstore.go @@ -1,6 +1,8 @@ package appstore import ( + "time" + "github.com/majd/ipatool/v2/pkg/anisette" "github.com/majd/ipatool/v2/pkg/gsa" "github.com/majd/ipatool/v2/pkg/http" @@ -62,20 +64,25 @@ type gsaClient interface { } type appstore struct { - keychain keychain.Keychain - cookieJar http.CookieJar - loginClient http.Client[loginResult] - searchClient http.Client[searchResult] - purchaseClient http.Client[purchaseResult] - downloadClient http.Client[downloadResult] - platformClient http.Client[platformVersionLookupResult] - bagClient http.Client[bagResult] - ownedAppsClient http.Client[[]byte] - httpClient http.Client[interface{}] - machine machine.Machine - os operatingsystem.OperatingSystem - gsa gsaClient + keychain keychain.Keychain + cookieJar http.CookieJar + storefrontClient http.Client[[]byte] + loginClient http.Client[loginResult] + searchClient http.Client[searchResult] + purchaseClient http.Client[purchaseResult] + downloadClient http.Client[downloadResult] + platformClient http.Client[platformVersionLookupResult] + bagClient http.Client[bagResult] + ownedAppsClient http.Client[[]byte] + httpClient http.Client[interface{}] + machine machine.Machine + os operatingsystem.OperatingSystem + gsa gsaClient anisette anisette.Provider + + // authRetrySleep waits between authentication request retries. Tests + // replace it to avoid real delays. + authRetrySleep func(delay time.Duration) } type Args struct { @@ -92,19 +99,20 @@ func NewAppStore(args Args) AppStore { } return &appstore{ - keychain: args.Keychain, - cookieJar: args.CookieJar, - loginClient: http.NewClient[loginResult](clientArgs), - searchClient: http.NewClient[searchResult](clientArgs), - purchaseClient: http.NewClient[purchaseResult](clientArgs), - downloadClient: http.NewClient[downloadResult](clientArgs), - platformClient: http.NewClient[platformVersionLookupResult](clientArgs), - bagClient: http.NewClient[bagResult](clientArgs), - ownedAppsClient: http.NewClient[[]byte](clientArgs), - httpClient: http.NewClient[interface{}](clientArgs), - machine: args.Machine, - os: args.OperatingSystem, - gsa: gsa.NewClient(args.CookieJar), - anisette: anisette.NewProvider(nil), + keychain: args.Keychain, + cookieJar: args.CookieJar, + storefrontClient: http.NewClient[[]byte](clientArgs), + loginClient: http.NewClient[loginResult](http.Args{CookieJar: args.CookieJar, ActionSigner: mescal.Sign, Authentication: true}), + searchClient: http.NewClient[searchResult](clientArgs), + purchaseClient: http.NewClient[purchaseResult](clientArgs), + downloadClient: http.NewClient[downloadResult](clientArgs), + platformClient: http.NewClient[platformVersionLookupResult](clientArgs), + bagClient: http.NewClient[bagResult](clientArgs), + ownedAppsClient: http.NewClient[[]byte](clientArgs), + httpClient: http.NewClient[interface{}](clientArgs), + machine: args.Machine, + os: args.OperatingSystem, + gsa: gsa.NewClient(args.CookieJar), + anisette: anisette.NewProvider(nil), } } diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index d13643d..a1e5fc9 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -2,9 +2,11 @@ package appstore import ( "archive/zip" + "context" "errors" "fmt" "io" + gohttp "net/http" "os" "path/filepath" "strconv" @@ -271,6 +273,15 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { } } + if externalVersionID == "" && input.Platform == PlatformMacOS { + // The legacy MDM lookup can return an iOS offer even for Mac + // downloads, so the Mac product page is consulted instead. + externalVersionID, err = t.lookupLatestMacOSExternalVersionID(input.Account, input.App) + if err != nil { + return DownloadOutput{}, fmt.Errorf("failed to resolve latest macOS version for download: %w", err) + } + } + item, err := t.fetchDownloadItem(input.Account, input.App, guid, externalVersionID) if err != nil { return DownloadOutput{}, err @@ -298,7 +309,12 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err) } - err = t.applyPatches(item, input.Account, tmpPath, destination) + artwork, err := t.downloadArtwork(context.Background(), item.ArtworkURL) + if err != nil { + return DownloadOutput{}, fmt.Errorf("failed to download artwork: %w", err) + } + + err = t.applyPatches(item, input.Account, tmpPath, destination, artwork) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) } @@ -399,10 +415,11 @@ func (*appstore) validatePackagePlatform(path string, platform Platform) error { } type downloadItemResult struct { - HashMD5 string `plist:"md5,omitempty"` - URL string `plist:"URL,omitempty"` - Sinfs []Sinf `plist:"sinfs,omitempty"` - Metadata map[string]interface{} `plist:"metadata,omitempty"` + ArtworkURL string `plist:"artworkURL,omitempty"` + HashMD5 string `plist:"md5,omitempty"` + URL string `plist:"URL,omitempty"` + Sinfs []Sinf `plist:"sinfs,omitempty"` + Metadata map[string]interface{} `plist:"metadata,omitempty"` } type downloadResult struct { @@ -411,7 +428,8 @@ type downloadResult struct { Items []downloadItemResult `plist:"songList,omitempty"` } -func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar, progressWriter io.Writer, onTotal func(int64)) error { +//nolint:nonamedreturns // Deferred close errors must propagate to callers. +func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressBar, progressWriter io.Writer, onTotal func(int64)) (err error) { req, err := t.httpClient.NewRequest("GET", src, nil) if err != nil { return fmt.Errorf("failed to create request: %w", err) @@ -422,7 +440,11 @@ func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressB return fmt.Errorf("failed to open file: %w", err) } - defer file.Close() + defer func() { + if closeErr := file.Close(); closeErr != nil { + err = joinCleanupError(err, "failed to close downloaded file", closeErr) + } + }() stat, err := t.os.Stat(dst) if err != nil { @@ -439,41 +461,60 @@ func (t *appstore) downloadFile(src, dst string, progress *progressbar.ProgressB } defer res.Body.Close() - total := res.ContentLength + stat.Size() - if onTotal != nil { - onTotal(total) + offset, remaining, total, complete, err := downloadResponseRange(res, stat.Size()) + if err != nil { + return err } - if progress != nil { - progress.ChangeMax64(total) - err = progress.Set64(stat.Size()) - if err != nil { - return fmt.Errorf("can not set bar progress: %w", err) + if complete { + return nil + } + + if res.StatusCode == gohttp.StatusOK { + if err := file.Truncate(0); err != nil { + return fmt.Errorf("failed to restart download: %w", err) } } - // Seek to the end so a resumed download appends after the already-downloaded - // range (for a fresh file this is a no-op at offset 0). - _, err = file.Seek(0, io.SeekEnd) - if err != nil { + if _, err := file.Seek(offset, io.SeekStart); err != nil { return fmt.Errorf("can not seek file: %w", err) } + if onTotal != nil { + onTotal(total) + } + // The CLI progress bar (progress) and any raw-bytes tracker (progressWriter, // e.g. the GUI progress tracker) each receive the raw downloaded bytes. writers := []io.Writer{file} + if progress != nil { + progress.ChangeMax64(total) + + if err := progress.Set64(offset); err != nil { + return fmt.Errorf("can not set bar progress: %w", err) + } + writers = append(writers, progress) } if progressWriter != nil { writers = append(writers, progressWriter) } - _, err = io.Copy(io.MultiWriter(writers...), res.Body) + var body io.Reader = res.Body + if remaining >= 0 { + body = io.LimitReader(body, remaining) + } + + written, err := io.Copy(io.MultiWriter(writers...), body) if err != nil { return fmt.Errorf("failed to write file: %w", err) } + if remaining >= 0 && written != remaining || total >= 0 && offset+written != total { + return fmt.Errorf("download is incomplete: %w", io.ErrUnexpectedEOF) + } + return nil } @@ -622,7 +663,7 @@ func (t *appstore) isDirectory(path string) (bool, error) { return info.IsDir(), nil } -func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string) error { +func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string, artwork []byte) error { srcZip, err := zip.OpenReader(src) if err != nil { return fmt.Errorf("failed to open zip reader: %w", err) @@ -638,11 +679,22 @@ func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst s dstZip := zip.NewWriter(dstFile) defer dstZip.Close() - err = t.replicateZip(srcZip, dstZip) + err = t.replicateZip(srcZip, dstZip, src) if err != nil { return fmt.Errorf("failed to replicate zip: %w", err) } + if len(artwork) != 0 { + file, err := dstZip.Create("iTunesArtwork") + if err != nil { + return fmt.Errorf("failed to create artwork: %w", err) + } + + if _, err := file.Write(artwork); err != nil { + return fmt.Errorf("failed to write artwork: %w", err) + } + } + err = t.writeMetadata(item.Metadata, acc, dstZip) if err != nil { return fmt.Errorf("failed to write metadata: %w", err) diff --git a/pkg/appstore/appstore_download_artwork.go b/pkg/appstore/appstore_download_artwork.go new file mode 100644 index 0000000..7accf0d --- /dev/null +++ b/pkg/appstore/appstore_download_artwork.go @@ -0,0 +1,46 @@ +package appstore + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" +) + +func (t *appstore) downloadArtwork(ctx context.Context, url string) ([]byte, error) { + if url == "" { + return nil, nil + } + + if ctx == nil { + ctx = context.Background() + } + + req, err := t.httpClient.NewRequest(http.MethodGet, url, nil) + if err != nil { + return nil, fmt.Errorf("failed to create request: %w", err) + } + + res, err := t.httpClient.Do(req.WithContext(ctx)) + if err != nil { + return nil, fmt.Errorf("request failed: %w", err) + } + + defer res.Body.Close() + + if res.StatusCode != http.StatusOK { + return nil, fmt.Errorf("unexpected artwork response status: %d", res.StatusCode) + } + + data, err := io.ReadAll(res.Body) + if err != nil { + return nil, fmt.Errorf("failed to read artwork: %w", err) + } + + if len(data) == 0 { + return nil, errors.New("artwork response is empty") + } + + return data, nil +} diff --git a/pkg/appstore/appstore_download_artwork_test.go b/pkg/appstore/appstore_download_artwork_test.go new file mode 100644 index 0000000..12c4faf --- /dev/null +++ b/pkg/appstore/appstore_download_artwork_test.go @@ -0,0 +1,112 @@ +package appstore + +import ( + "archive/zip" + "context" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + apphttp "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" +) + +func TestDownloadArtworkAndIncludeInIPA(t *testing.T) { + requests := 0 + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + requests++ + _, _ = io.WriteString(w, "artwork bytes") + })) + defer server.Close() + + store := &appstore{httpClient: apphttp.NewClient[interface{}](apphttp.Args{}), os: operatingsystem.New()} + for _, url := range []string{"", server.URL} { + data, err := store.downloadArtwork(context.Background(), url) + if err != nil { + t.Fatal(err) + } + + if url == "" && (len(data) != 0 || requests != 0) { + t.Fatal("artwork was downloaded without a URL") + } + + if url != "" && string(data) != "artwork bytes" { + t.Fatal("artwork missing") + } + + path := filepath.Join(t.TempDir(), "source.zip") + + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + + writer := zip.NewWriter(file) + if _, err := writer.Create("Payload/App.app/"); err != nil { + t.Fatal(err) + } + + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + if err := file.Close(); err != nil { + t.Fatal(err) + } + + if err := store.applyPatches(downloadItemResult{Metadata: map[string]interface{}{}}, Account{}, path, path+".ipa", data); err != nil { + t.Fatal(err) + } + + reader, err := zip.OpenReader(path + ".ipa") + if err != nil { + t.Fatal(err) + } + + found := false + + for _, entry := range reader.File { + if entry.Name != "iTunesArtwork" { + continue + } + + found = true + + r, err := entry.Open() + if err != nil { + t.Fatal(err) + } + + actual, err := io.ReadAll(r) + r.Close() + + if err != nil || string(actual) != "artwork bytes" { + t.Fatalf("artwork = %q; %v", actual, err) + } + } + + reader.Close() + + if found != (url != "") { + t.Fatal("incorrect artwork presence") + } + } +} + +func TestDownloadArtworkRejectsFailedResponse(t *testing.T) { + server := httptest.NewServer(http.NotFoundHandler()) + defer server.Close() + + store := &appstore{httpClient: apphttp.NewClient[interface{}](apphttp.Args{})} + if _, err := store.downloadArtwork(context.Background(), server.URL); err == nil { + t.Fatal("accepted error page as artwork") + } + + if data, err := store.downloadArtwork(context.Background(), ""); err != nil || len(data) != 0 { + t.Fatal("missing artwork URL should be skipped") + } +} diff --git a/pkg/appstore/appstore_download_range.go b/pkg/appstore/appstore_download_range.go new file mode 100644 index 0000000..a537843 --- /dev/null +++ b/pkg/appstore/appstore_download_range.go @@ -0,0 +1,47 @@ +package appstore + +import ( + "fmt" + "net/http" + "strconv" + "strings" +) + +// downloadResponseRange validates the response before any partial bytes change. +// It returns the write offset, remaining bytes, total size, and completion state. +func downloadResponseRange(res *http.Response, localSize int64) (int64, int64, int64, bool, error) { + switch res.StatusCode { + case http.StatusOK: + return 0, res.ContentLength, res.ContentLength, false, nil + case http.StatusRequestedRangeNotSatisfiable: + value, found := strings.CutPrefix(res.Header.Get("Content-Range"), "bytes */") + size, parseErr := strconv.ParseInt(value, 10, 64) + + if found && parseErr == nil && size >= 0 && size == localSize { + return localSize, 0, size, true, nil + } + + return 0, 0, 0, false, fmt.Errorf("download range rejected: local size %d does not match server range %q", localSize, res.Header.Get("Content-Range")) + case http.StatusPartialContent: + header := res.Header.Get("Content-Range") + value, found := strings.CutPrefix(header, "bytes ") + bounds, totalText, hasTotal := strings.Cut(value, "/") + startText, endText, hasEnd := strings.Cut(bounds, "-") + start, startErr := strconv.ParseInt(startText, 10, 64) + end, endErr := strconv.ParseInt(endText, 10, 64) + size, sizeErr := strconv.ParseInt(totalText, 10, 64) + + if !found || !hasTotal || !hasEnd || startErr != nil || endErr != nil || sizeErr != nil || start != localSize || start < 0 || end < start || size <= end { + return 0, 0, 0, false, fmt.Errorf("invalid download content range %q for local size %d", header, localSize) + } + + length := end - start + 1 + if res.ContentLength >= 0 && res.ContentLength != length { + return 0, 0, 0, false, fmt.Errorf("download content length %d does not match range length %d", res.ContentLength, length) + } + + return start, length, size, false, nil + default: + return 0, 0, 0, false, fmt.Errorf("unexpected download response status: %d", res.StatusCode) + } +} diff --git a/pkg/appstore/appstore_download_resume_test.go b/pkg/appstore/appstore_download_resume_test.go new file mode 100644 index 0000000..d626829 --- /dev/null +++ b/pkg/appstore/appstore_download_resume_test.go @@ -0,0 +1,130 @@ +package appstore + +import ( + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "testing" + + apphttp "github.com/majd/ipatool/v2/pkg/http" + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" + "github.com/schollz/progressbar/v3" +) + +func TestDownloadFileResumesWithAndWithoutProgress(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Range"); got != "bytes=7-" { + t.Errorf("range = %q", got) + } + + w.Header().Set("Content-Range", "bytes 7-14/15") + w.WriteHeader(http.StatusPartialContent) + _, _ = io.WriteString(w, "download") + })) + defer server.Close() + + for _, interactive := range []bool{false, true} { + t.Run(map[bool]string{false: "non-interactive", true: "interactive"}[interactive], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.tmp") + if err := os.WriteFile(path, []byte("partial"), 0600); err != nil { + t.Fatal(err) + } + + store := &appstore{httpClient: apphttp.NewClient[interface{}](apphttp.Args{}), os: operatingsystem.New()} + + var progress *progressbar.ProgressBar + if interactive { + progress = progressbar.NewOptions64(15, progressbar.OptionSetWriter(io.Discard)) + } + + if err := store.downloadFile(server.URL, path, progress, nil, nil); err != nil { + t.Fatal(err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if string(data) != "partialdownload" { + t.Fatalf("download = %q", data) + } + }) + } +} + +func TestDownloadFileValidatesResumeResponses(t *testing.T) { + for _, test := range []struct { + name string + status int + rangeHeader string + body string + want string + wantError bool + chunked bool + }{ + {name: "range ignored", status: http.StatusOK, body: "new", want: "new"}, + {name: "already complete", status: http.StatusRequestedRangeNotSatisfiable, rangeHeader: "bytes */7", body: "error page", want: "partial"}, + {name: "stale oversized partial", status: http.StatusRequestedRangeNotSatisfiable, rangeHeader: "bytes */3", body: "error page", want: "partial", wantError: true}, + {name: "incomplete rejected partial", status: http.StatusRequestedRangeNotSatisfiable, rangeHeader: "bytes */15", want: "partial", wantError: true}, + {name: "malformed unsatisfied range", status: http.StatusRequestedRangeNotSatisfiable, rangeHeader: "7", want: "partial", wantError: true}, + {name: "http error", status: http.StatusForbidden, body: "error page", want: "partial", wantError: true}, + {name: "wrong offset", status: http.StatusPartialContent, rangeHeader: "bytes 0-7/15", body: "download", want: "partial", wantError: true}, + {name: "missing range", status: http.StatusPartialContent, body: "download", want: "partial", wantError: true}, + {name: "malformed range", status: http.StatusPartialContent, rangeHeader: "bytes 7-x/15", body: "download", want: "partial", wantError: true}, + {name: "invalid total", status: http.StatusPartialContent, rangeHeader: "bytes 7-14/14", body: "download", want: "partial", wantError: true}, + {name: "length mismatch", status: http.StatusPartialContent, rangeHeader: "bytes 7-14/15", body: "bad", want: "partial", wantError: true}, + {name: "truncated chunked response", status: http.StatusPartialContent, rangeHeader: "bytes 7-14/15", body: "dow", want: "partialdow", wantError: true, chunked: true}, + } { + for _, interactive := range []bool{false, true} { + t.Run(test.name+map[bool]string{false: "/non-interactive", true: "/interactive"}[interactive], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.tmp") + if err := os.WriteFile(path, []byte("partial"), 0600); err != nil { + t.Fatal(err) + } + + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Range") != "bytes=7-" { + t.Errorf("range = %q", r.Header.Get("Range")) + } + + if test.rangeHeader != "" { + w.Header().Set("Content-Range", test.rangeHeader) + } + + w.WriteHeader(test.status) + + if test.chunked { + w.(http.Flusher).Flush() + } + + _, _ = io.WriteString(w, test.body) + })) + defer server.Close() + + store := &appstore{httpClient: apphttp.NewClient[interface{}](apphttp.Args{}), os: operatingsystem.New()} + + var progress *progressbar.ProgressBar + if interactive { + progress = progressbar.NewOptions64(1, progressbar.OptionSetWriter(io.Discard)) + } + + err := store.downloadFile(server.URL, path, progress, nil, nil) + if (err != nil) != test.wantError { + t.Fatalf("download error = %v", err) + } + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + if string(data) != test.want { + t.Fatalf("file = %q, want %q", data, test.want) + } + }) + } + } +} diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 98904a8..5285f78 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -39,6 +39,7 @@ var _ = Describe("AppStore (Download)", func() { mockPlatformClient *http.MockClient[platformVersionLookupResult] mockPurchaseClient *http.MockClient[purchaseResult] mockLoginClient *http.MockClient[loginResult] + mockStorefront *http.MockClient[[]byte] mockHTTPClient *http.MockClient[interface{}] mockOS *operatingsystem.MockOperatingSystem mockMachine *machine.MockMachine @@ -53,17 +54,19 @@ var _ = Describe("AppStore (Download)", func() { mockLoginClient = http.NewMockClient[loginResult](ctrl) mockPurchaseClient = http.NewMockClient[purchaseResult](ctrl) mockHTTPClient = http.NewMockClient[interface{}](ctrl) + mockStorefront = http.NewMockClient[[]byte](ctrl) mockOS = operatingsystem.NewMockOperatingSystem(ctrl) mockMachine = machine.NewMockMachine(ctrl) as = &appstore{ - keychain: mockKeychain, - loginClient: mockLoginClient, - purchaseClient: mockPurchaseClient, - downloadClient: mockDownloadClient, - platformClient: mockPlatformClient, - httpClient: mockHTTPClient, - machine: mockMachine, - os: mockOS, + keychain: mockKeychain, + loginClient: mockLoginClient, + purchaseClient: mockPurchaseClient, + downloadClient: mockDownloadClient, + platformClient: mockPlatformClient, + storefrontClient: mockStorefront, + httpClient: mockHTTPClient, + machine: mockMachine, + os: mockOS, } }) @@ -187,6 +190,47 @@ var _ = Describe("AppStore (Download)", func() { }) }) + When("the platform is macOS and no external version id is provided", func() { + BeforeEach(func() { + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + mockStorefront.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.URL).To(Equal("https://apps.apple.com/de/app/id6472431552?platform=mac")) + }). + Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: macVersionPage(karingMacConfiguration), + }, nil) + + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + payload, ok := req.Payload.(*http.XMLPayload) + Expect(ok).To(BeTrue()) + Expect(payload.Content["externalVersionId"]).To(Equal("876660716")) + }). + Return(http.Result[downloadResult]{}, errors.New("request error")) + }) + + It("resolves and sends the macOS external version id", func() { + _, err := as.Download(DownloadInput{ + Account: Account{ + StoreFront: "143443", + }, + App: App{ + ID: 6472431552, + BundleID: "com.nebula.karing", + }, + Platform: PlatformMacOS, + }) + Expect(err).To(HaveOccurred()) + }) + }) + DescribeTable("platform uses the standard download request", func(platform Platform) { mockMachine.EXPECT(). @@ -487,7 +531,9 @@ var _ = Describe("AppStore (Download)", func() { mockHTTPClient.EXPECT(). Do(gomock.Any()). Return(&gohttp.Response{ - Body: io.NopCloser(strings.NewReader("ping")), + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(strings.NewReader("ping")), + ContentLength: 4, }, nil) }) @@ -547,7 +593,9 @@ var _ = Describe("AppStore (Download)", func() { mockHTTPClient.EXPECT(). Do(gomock.Any()). Return(&gohttp.Response{ - Body: io.NopCloser(strings.NewReader("ping")), + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(strings.NewReader("ping")), + ContentLength: 4, }, nil) }) @@ -589,7 +637,7 @@ var _ = Describe("AppStore (Download)", func() { mockOS.EXPECT(). Stat(gomock.Any()). - Return(nil, nil) + Return(&dummyFileInfo{}, nil) mockOS.EXPECT(). Remove(tmpFile.Name()). @@ -665,4 +713,50 @@ var _ = Describe("AppStore (Download)", func() { Expect(err.Error()).To(ContainSubstring("AppleTVOS")) }) }) + + Describe("resuming a download", func() { + It("appends the ranged response to the partial file", func() { + testFile, err := os.CreateTemp("", "ipatool-download-*") + Expect(err).ToNot(HaveOccurred()) + defer os.Remove(testFile.Name()) + + _, err = testFile.WriteString("partial-") + Expect(err).ToNot(HaveOccurred()) + _, err = testFile.Seek(0, io.SeekStart) + Expect(err).ToNot(HaveOccurred()) + + request := &gohttp.Request{Header: make(gohttp.Header)} + info, err := testFile.Stat() + Expect(err).ToNot(HaveOccurred()) + + mockHTTPClient.EXPECT(). + NewRequest("GET", "https://example.com/app.ipa", nil). + Return(request, nil) + mockOS.EXPECT(). + OpenFile(testFile.Name(), os.O_CREATE|os.O_RDWR, os.FileMode(0644)). + Return(testFile, nil) + mockOS.EXPECT(). + Stat(testFile.Name()). + Return(info, nil) + mockHTTPClient.EXPECT(). + Do(request). + DoAndReturn(func(request *gohttp.Request) (*gohttp.Response, error) { + Expect(request.Header.Get("range")).To(Equal("bytes=8-")) + + return &gohttp.Response{ + StatusCode: gohttp.StatusPartialContent, + Header: gohttp.Header{"Content-Range": []string{"bytes 8-16/17"}}, + ContentLength: 9, + Body: io.NopCloser(strings.NewReader("remainder")), + }, nil + }) + + err = as.(*appstore).downloadFile("https://example.com/app.ipa", testFile.Name(), nil, nil, nil) + Expect(err).ToNot(HaveOccurred()) + + data, err := os.ReadFile(testFile.Name()) + Expect(err).ToNot(HaveOccurred()) + Expect(string(data)).To(Equal("partial-remainder")) + }) + }) }) diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index 3acb9c0..ff561a8 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -9,6 +9,7 @@ import ( "runtime" "strconv" "strings" + "time" "github.com/majd/ipatool/v2/pkg/gsa" "github.com/majd/ipatool/v2/pkg/http" @@ -21,6 +22,12 @@ var ( const legacyAuthenticateEndpoint = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" +const ( + maxAuthenticationRequestAttempts = 3 + authenticationRetryDelay = 10 * time.Second + maxAuthenticationRetryDelay = 30 * time.Second +) + type LoginInput struct { Email string Password string @@ -233,14 +240,23 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string) (Acco request := t.loginRequest(email, password, authCode, guid, endpoint, requestAttempt) request.URL, _ = util.IfEmpty(redirect, request.URL), "" - res, err = t.loginClient.Send(request) + res, err = t.sendAuthenticationRequest(request) if err != nil { if shouldRetryWithLegacyAuthenticate(endpoint, err) { return t.login(email, password, authCode, guid, legacyAuthenticateEndpoint) } - return Account{}, fmt.Errorf("request failed: %w", err) + stage := "sign-in" + if authCode != "" { + stage = "2FA verification" + } + + if redirect != "" { + stage += " at Store pod" + } + + return Account{}, fmt.Errorf("%s request failed: %w", stage, err) } if retry, redirect, err = t.parseLoginResponse(&res, authCode); err != nil { @@ -286,6 +302,109 @@ func (t *appstore) login(email, password, authCode, guid, endpoint string) (Acco return acc, nil } +// sendAuthenticationRequest repeats an authentication request while Apple +// answers with a transient failure (empty 204, 404, 429 or a 5xx gateway +// error), honoring a Retry-After header when Apple sends one. +func (t *appstore) sendAuthenticationRequest(request http.Request) (http.Result[loginResult], error) { + statuses := make([]string, 0, maxAuthenticationRequestAttempts) + + sleep := t.authRetrySleep + if sleep == nil { + sleep = time.Sleep + } + + for attempt := 1; ; attempt++ { + result, err := t.loginClient.Send(request) + + status, retry := retryableAuthenticationError(err) + if !retry { + if err != nil { + return result, authenticationRequestError(err) + } + + return result, nil + } + + statuses = append(statuses, strconv.Itoa(status)) + + if attempt == maxAuthenticationRequestAttempts { + return result, fmt.Errorf( + "authentication request failed after %d attempts (HTTP %s): %w", + maxAuthenticationRequestAttempts, strings.Join(statuses, ", "), authenticationRequestError(err), + ) + } + + delay := min(authenticationRetryDelay<<(attempt-1), maxAuthenticationRetryDelay) + + var responseErr *http.UnexpectedResponseError + if errors.As(err, &responseErr) { + if requested, ok := authenticationRetryAfter(responseErr.RetryAfter, time.Now()); ok { + if requested > maxAuthenticationRetryDelay { + return result, fmt.Errorf("apple requested a wait longer than %s; try again later: %w", maxAuthenticationRetryDelay, err) + } + + // Retry-After takes precedence over the fallback backoff. + delay = max(requested, time.Second) + } + } + + sleep(delay) + } +} + +// retryableAuthenticationError reports whether the authentication failure is +// transient enough to be worth another attempt. +func retryableAuthenticationError(err error) (int, bool) { + var responseErr *http.UnexpectedResponseError + if !errors.As(err, &responseErr) { + return 0, false + } + + status := responseErr.StatusCode + retry := status == gohttp.StatusNoContent || + status == gohttp.StatusNotFound || + status == gohttp.StatusTooManyRequests || + status/100 == 5 + + return status, retry +} + +// authenticationRequestError turns a raw unexpected-response failure into a +// message that tells the user what to do instead of leaking Apple's HTML. +func authenticationRequestError(err error) error { + var responseErr *http.UnexpectedResponseError + if !errors.As(err, &responseErr) { + return err + } + + if responseErr.StatusCode == gohttp.StatusTooManyRequests { + return fmt.Errorf("apple rate limited authentication; try again later: %w", err) + } + + return fmt.Errorf("apple returned no usable authentication response; try again later or from another network: %w", err) +} + +// authenticationRetryAfter parses a Retry-After header value (delta-seconds +// or HTTP-date) into a wait duration. +func authenticationRetryAfter(value string, now time.Time) (time.Duration, bool) { + value = strings.TrimSpace(value) + if seconds, err := strconv.ParseUint(value, 10, 64); err == nil { + // Saturate before converting to Duration to avoid overflow. A wait over + // the budget ends this login rather than retrying before Apple's deadline. + if seconds > uint64(maxAuthenticationRetryDelay/time.Second) { + return maxAuthenticationRetryDelay + time.Second, true + } + + return time.Duration(seconds) * time.Second, true + } + + if date, err := gohttp.ParseTime(value); err == nil { + return max(time.Duration(0), date.Sub(now)), true + } + + return 0, false +} + func shouldRetryWithLegacyAuthenticate(endpoint string, err error) bool { if !strings.Contains(endpoint, "/native/") { return false @@ -325,7 +444,7 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], authCode st if res.Data.CustomerMessage != "" { err = NewErrorWithMetadata(errors.New(res.Data.CustomerMessage), res) } else { - err = NewErrorWithMetadata(fmt.Errorf("something went wrong (failure type %s)", res.Data.FailureType), res) + err = NewErrorWithMetadata(fmt.Errorf("apple returned no usable authentication response (HTTP %d): missing account credentials or unexpected status; try again later or from another network", res.StatusCode), res) } } else if res.StatusCode != gohttp.StatusOK || res.Data.PasswordToken == "" || res.Data.DirectoryServicesID == "" { err = NewErrorWithMetadata(errors.New("something went wrong"), res) diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index 6cfb32c..af9857f 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -257,7 +257,7 @@ var _ = Describe("AppStore (Login)", func() { _, err := as.Login(LoginInput{ Password: testPassword, }) - Expect(err).To(MatchError("request failed: test complete")) + Expect(err).To(MatchError("sign-in at Store pod request failed: test complete")) }) }) diff --git a/pkg/appstore/appstore_macos_version_lookup.go b/pkg/appstore/appstore_macos_version_lookup.go new file mode 100644 index 0000000..9616ce9 --- /dev/null +++ b/pkg/appstore/appstore_macos_version_lookup.go @@ -0,0 +1,131 @@ +package appstore + +import ( + "bytes" + "encoding/json" + "errors" + "fmt" + gohttp "net/http" + "net/url" + "slices" + "strconv" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" +) + +const serializedServerDataID = "serialized-server-data" + +// serializedServerData extracts the JSON blob Apple embeds in its storefront +// pages inside the serialized-server-data script tag. +func serializedServerData(body []byte) ([]byte, error) { + markerIndex := bytes.Index(body, []byte(`id="`+serializedServerDataID+`"`)) + if markerIndex == -1 { + markerIndex = bytes.Index(body, []byte(`id='`+serializedServerDataID+`'`)) + } + + if markerIndex == -1 { + return nil, errors.New("serialized server data was not found") + } + + scriptStart := bytes.LastIndex(body[:markerIndex], []byte("') + if contentOffset == -1 { + return nil, errors.New("serialized server data script is malformed") + } + + contentStart := markerIndex + contentOffset + 1 + + contentEndOffset := bytes.Index(body[contentStart:], []byte("")) + if contentEndOffset == -1 { + return nil, errors.New("serialized server data script is not closed") + } + + return bytes.TrimSpace(body[contentStart : contentStart+contentEndOffset]), nil +} + +func (t *appstore) lookupLatestMacOSExternalVersionID(acc Account, app App) (string, error) { + countryCode, err := countryCodeFromStoreFront(acc.StoreFront) + if err != nil { + return "", fmt.Errorf("failed to resolve the country code: %w", err) + } + + // The legacy MDM lookup can return an iOS offer even with platform=osx. + // Use the Mac product page to select the native Mac offer instead. + res, err := t.storefrontClient.Send(http.Request{ + URL: fmt.Sprintf("https://apps.apple.com/%s/app/id%d?platform=mac", strings.ToLower(countryCode), app.ID), + Method: http.MethodGET, + ResponseFormat: http.ResponseFormatRaw, + }) + if err != nil { + return "", fmt.Errorf("macOS version lookup request failed: %w", err) + } + + if res.StatusCode != gohttp.StatusOK { + return "", fmt.Errorf("macOS version lookup returned HTTP %d", res.StatusCode) + } + + return macOSExternalVersionID(res.Data, app) +} + +func macOSExternalVersionID(body []byte, app App) (string, error) { + data, err := serializedServerData(body) + if err != nil { + return "", err + } + + var value interface{} + if err := json.Unmarshal(data, &value); err != nil { + return "", fmt.Errorf("failed to decode Mac product page: %w", err) + } + + versions := make(map[string]struct{}) + collectMacOSExternalVersions(value, app, versions) + + if len(versions) == 0 { + return "", errors.New("macOS purchase configuration has no external version id for the requested app") + } + + if len(versions) != 1 { + return "", errors.New("macOS purchase configurations contain conflicting external version ids") + } + + for version := range versions { + return version, nil + } + + return "", errors.New("macOS external version id was not found") +} + +func collectMacOSExternalVersions(value interface{}, app App, versions map[string]struct{}) { + switch value := value.(type) { + case []interface{}: + for _, child := range value { + collectMacOSExternalVersions(child, app, versions) + } + case map[string]interface{}: + if configuration, ok := value["purchaseConfiguration"].(map[string]interface{}); ok { + platforms, _ := configuration["appPlatforms"].([]interface{}) + bundleID, _ := configuration["bundleId"].(string) + buyParams, _ := configuration["buyParams"].(string) + + params, err := url.ParseQuery(buyParams) + if err == nil && slices.Contains(platforms, interface{}("mac")) && + params.Get("salableAdamId") == strconv.FormatInt(app.ID, 10) && + (app.BundleID == "" || app.BundleID == bundleID) { + version := params.Get("appExtVrsId") + if id, err := strconv.ParseUint(version, 10, 64); err == nil && id != 0 { + versions[version] = struct{}{} + } + } + } + + for _, child := range value { + collectMacOSExternalVersions(child, app, versions) + } + } +} diff --git a/pkg/appstore/appstore_macos_version_lookup_test.go b/pkg/appstore/appstore_macos_version_lookup_test.go new file mode 100644 index 0000000..08ddedd --- /dev/null +++ b/pkg/appstore/appstore_macos_version_lookup_test.go @@ -0,0 +1,54 @@ +package appstore + +import ( + "errors" + + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +const karingMacConfiguration = `{"purchaseConfiguration":{"bundleId":"com.nebula.karing","appPlatforms":["mac","tv","phone","pad"],"metricsPlatformDisplayStyle":"ios","buyParams":"salableAdamId=6472431552&appExtVrsId=876660716"}}` + +func macVersionPage(configuration string) []byte { + return []byte(``) +} + +var _ = Describe("Mac purchase version selection", func() { + app := App{ID: 6472431552, BundleID: "com.nebula.karing"} + + It("selects Karing's Mac offer and ignores unrelated and iOS offers", func() { + body := macVersionPage(`[{"purchaseConfiguration":{"bundleId":"com.nebula.karing","appPlatforms":["phone","pad"],"buyParams":"salableAdamId=6472431552&appExtVrsId=891116578"}}, {"purchaseConfiguration":{"appPlatforms":["mac"],"buyParams":"salableAdamId=100&appExtVrsId=123"}}, ` + karingMacConfiguration + `]`) + version, err := macOSExternalVersionID(body, app) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("876660716")) + }) + + DescribeTable("rejects invalid catalog metadata", func(configuration string) { + _, err := macOSExternalVersionID(macVersionPage(configuration), app) + Expect(err).To(HaveOccurred()) + }, + Entry("invalid JSON", `{`), + Entry("no offer", `{}`), + Entry("iOS only", `{"purchaseConfiguration":{"bundleId":"com.nebula.karing","appPlatforms":["phone","pad"],"buyParams":"salableAdamId=6472431552&appExtVrsId=891116578"}}`), + Entry("wrong bundle", `{"purchaseConfiguration":{"bundleId":"other","appPlatforms":["mac"],"buyParams":"salableAdamId=6472431552&appExtVrsId=123"}}`), + Entry("invalid version", `{"purchaseConfiguration":{"bundleId":"com.nebula.karing","appPlatforms":["mac"],"buyParams":"salableAdamId=6472431552&appExtVrsId=invalid"}}`), + Entry("conflicting offers", `[`+karingMacConfiguration+`,{"purchaseConfiguration":{"bundleId":"com.nebula.karing","appPlatforms":["mac"],"buyParams":"salableAdamId=6472431552&appExtVrsId=123"}}]`), + ) + + + DescribeTable("propagates lookup failures", func(status int, body []byte, requestErr error) { + ctrl := gomock.NewController(GinkgoT()) + defer ctrl.Finish() + pages := http.NewMockClient[[]byte](ctrl) + store := &appstore{storefrontClient: pages} + pages.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: status, Data: body}, requestErr) + _, err := store.lookupLatestMacOSExternalVersionID(Account{StoreFront: "143443-2,34"}, app) + Expect(err).To(HaveOccurred()) + }, + Entry("HTTP failure", 500, nil, nil), + Entry("network failure", 0, nil, errors.New("connection reset")), + Entry("missing Mac offer", 200, macVersionPage(`{}`), nil), + ) +}) diff --git a/pkg/appstore/appstore_owned_apps.go b/pkg/appstore/appstore_owned_apps.go index 9b876cc..6699062 100644 --- a/pkg/appstore/appstore_owned_apps.go +++ b/pkg/appstore/appstore_owned_apps.go @@ -6,6 +6,7 @@ import ( "fmt" "math" gohttp "net/http" + "slices" "sort" "strconv" "strings" @@ -32,7 +33,9 @@ const ( MaxOwnedAppsLimit = 100 // ownedAppsMediaKind is the extended media kind Apple uses for iOS apps. - ownedAppsMediaKind = 131072 + ownedAppsMediaKind = 131072 + ownedArcadeAppsMediaKind = 262144 + ownedMacAppsMediaKind = 67108864 ) type OwnedAppsInput struct { @@ -41,6 +44,9 @@ type OwnedAppsInput struct { Page int // Limit is the page size. Zero means DefaultOwnedAppsLimit. Limit int + // Platform filters the results by the device family an app is available + // on. Empty returns every platform. + Platform Platform // All returns every owned app in a single page, ignoring Page and Limit. All bool } @@ -72,6 +78,12 @@ func (t *appstore) OwnedApps(input OwnedAppsInput) (OwnedAppsOutput, error) { return OwnedAppsOutput{}, err } + if input.Platform != "" { + apps = slices.DeleteFunc(apps, func(app App) bool { + return !slices.Contains(app.Platforms, input.Platform) + }) + } + apps = ownedAppsSortedByPurchaseDate(apps) pageApps := apps @@ -88,6 +100,12 @@ func (t *appstore) OwnedApps(input OwnedAppsInput) (OwnedAppsOutput, error) { } func normalizeOwnedAppsInput(input OwnedAppsInput) (OwnedAppsInput, error) { + switch input.Platform { + case "", PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS: + default: + return OwnedAppsInput{}, fmt.Errorf("invalid platform %q", input.Platform) + } + if input.All { input.Page = 1 input.Limit = MaxOwnedAppsLimit @@ -117,6 +135,26 @@ func normalizeOwnedAppsInput(input OwnedAppsInput) (OwnedAppsInput, error) { } func (t *appstore) fetchOwnedApps(acc Account, guid string) ([]App, error) { + var apps []App + + storeFront, _, _ := strings.Cut(acc.StoreFront, ",") + // Configurator (34) lists mobile apps, while the Mac App Store (13) + // also lists Mac software and mobile apps available on Apple silicon. + for _, store := range []string{"34", "13"} { + acc.StoreFront = storeFront + "," + store + + results, err := t.fetchOwnedAppsForStorefront(acc, guid) + if err != nil { + return nil, err + } + + apps = append(apps, results...) + } + + return mergeOwnedApps(apps), nil +} + +func (t *appstore) fetchOwnedAppsForStorefront(acc Account, guid string) ([]App, error) { loginResult, err := t.ownedAppsClient.Send(t.ownedAppsLoginRequest(acc, guid)) if err != nil { return nil, fmt.Errorf("failed to open purchase history session: %w", err) @@ -139,7 +177,7 @@ func (t *appstore) fetchOwnedApps(acc Account, guid string) ([]App, error) { return nil, errors.New("purchase history login response did not contain a valid session ID") } - query := fmt.Sprintf("('com.apple.itunes.extended\\-media\\-kind:%d')", ownedAppsMediaKind) + query := fmt.Sprintf("('com.apple.itunes.extended\\-media\\-kind:%d','com.apple.itunes.extended\\-media\\-kind:%d','com.apple.itunes.extended\\-media\\-kind:%d')", ownedAppsMediaKind, ownedArcadeAppsMediaKind, ownedMacAppsMediaKind) updateResult, err := t.ownedAppsClient.Send(t.ownedAppsUpdateRequest(acc, guid, uint32(sessionID), query)) if err != nil { @@ -353,7 +391,6 @@ func firstDMAPUint(data []byte, target string) (uint64, bool, error) { func parseOwnedApps(data []byte) ([]App, error) { apps := make([]App, 0) - seen := make(map[int64]struct{}) err := walkDMAP(data, 0, func(tag string, payload []byte) error { if tag != "mlit" { @@ -369,12 +406,6 @@ func parseOwnedApps(data []byte) ([]App, error) { return nil } - if _, ok := seen[app.ID]; ok { - return nil - } - - seen[app.ID] = struct{}{} - apps = append(apps, app) return nil @@ -383,14 +414,71 @@ func parseOwnedApps(data []byte) ([]App, error) { return nil, err } - return apps, nil + return mergeOwnedApps(apps), nil +} + +func mergeOwnedApps(apps []App) []App { + merged := make([]App, 0, len(apps)) + + seen := make(map[int64]int) + for _, app := range apps { + if index, ok := seen[app.ID]; ok { + for _, platform := range app.Platforms { + if !slices.Contains(merged[index].Platforms, platform) { + merged[index].Platforms = append(merged[index].Platforms, platform) + } + } + + if app.PurchaseDate.After(merged[index].PurchaseDate) { + merged[index].PurchaseDate = app.PurchaseDate + } + + continue + } + + seen[app.ID] = len(merged) + merged = append(merged, app) + } + + for index := range merged { + if len(merged[index].Platforms) > 1 { + merged[index].Platforms = slices.DeleteFunc(merged[index].Platforms, func(platform Platform) bool { + return platform == PlatformUnknown + }) + } + } + + return merged } func parseOwnedApp(data []byte) (App, error) { - var app App + app := App{Platforms: []Platform{}} + + var mediaKind, supportedProducts uint64 err := walkDMAP(data, 0, func(tag string, payload []byte) error { switch tag { + case "aeMk", "aeSS": + var value uint64 + + switch len(payload) { + case 1: + value = uint64(payload[0]) + case 2: + value = uint64(binary.BigEndian.Uint16(payload)) + case 4: + value = uint64(binary.BigEndian.Uint32(payload)) + case 8: + value = binary.BigEndian.Uint64(payload) + default: + return fmt.Errorf("owned app %s has invalid integer length %d", tag, len(payload)) + } + + if tag == "aeMk" { + mediaKind = value + } else { + supportedProducts = value + } case "aeSI": id, err := dmapInt64(payload) if err != nil { @@ -419,6 +507,33 @@ func parseOwnedApp(data []byte) (App, error) { return nil }) + // Mac software uses a separate media kind; its supported-products bits + // do not describe mobile device support. + switch mediaKind { + case ownedMacAppsMediaKind: + app.Platforms = append(app.Platforms, PlatformMacOS) + case ownedAppsMediaKind, ownedArcadeAppsMediaKind: + // The purchase-history mask uses 8 for Mac and 16 for visionOS. + // It does not encode Apple TV support. + for _, product := range []struct { + mask uint64 + platform Platform + }{ + {1, PlatformIPhone}, + {2, PlatformIPad}, + {16, PlatformVisionOS}, + {8, PlatformMacOS}, + } { + if supportedProducts&product.mask != 0 { + app.Platforms = append(app.Platforms, product.platform) + } + } + } + + if len(app.Platforms) == 0 { + app.Platforms = []Platform{PlatformUnknown} + } + return app, err } diff --git a/pkg/appstore/appstore_owned_apps_test.go b/pkg/appstore/appstore_owned_apps_test.go index 4718447..93d9616 100644 --- a/pkg/appstore/appstore_owned_apps_test.go +++ b/pkg/appstore/appstore_owned_apps_test.go @@ -3,6 +3,7 @@ package appstore import ( "encoding/binary" "errors" + "math" gohttp "net/http" "time" @@ -17,7 +18,7 @@ var _ = Describe("AppStore (OwnedApps)", func() { const ( testDSID = "123456789" testGUID = "AABBCCDDEEFF" - testStoreFront = "143441" + testStoreFront = "143441-1,34" testToken = "password-token" testSessionID = uint32(42) testRevision = uint32(99) @@ -56,101 +57,114 @@ var _ = Describe("AppStore (OwnedApps)", func() { Return("aa:bb:cc:dd:ee:ff", nil) } - expectLogin := func() *gomock.Call { - return mockOwnedClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.Method).To(Equal(http.MethodPOST)) - Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/login")) - Expect(req.SignAction).To(BeFalse()) - Expect(req.Payload).To(BeNil()) - Expect(req.ResponseFormat).To(Equal(http.ResponseFormatRaw)) - expectOwnedAppsHeaders(req.Headers, account, testGUID) - }). - Return(http.Result[[]byte]{ - StatusCode: gohttp.StatusOK, - Data: dmapTag("mlog", dmapUint32("mlid", testSessionID)), - }, nil) - } - - expectUpdate := func() *gomock.Call { - return mockOwnedClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.Method).To(Equal(http.MethodPOST)) - Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/update")) - Expect(req.SignAction).To(BeTrue()) - Expect(req.Headers).To(HaveKeyWithValue("Content-Type", "application/x-www-form-urlencoded")) - expectOwnedAppsHeaders(req.Headers, account, testGUID) - - payload, ok := req.Payload.(*http.RawPayload) - Expect(ok).To(BeTrue()) - Expect(string(payload.Content)).To(Equal("session-id=42&revision-number=(null)&query=('com.apple.itunes.extended\\-media\\-kind:131072')")) - }). - Return(http.Result[[]byte]{ - StatusCode: gohttp.StatusOK, - Data: dmapTag("mupd", dmapUint32("musr", testRevision)), - }, nil) - } - - expectItems := func(apps []App) *gomock.Call { - return mockOwnedClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - Expect(req.Method).To(Equal(http.MethodPOST)) - Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/databases/99/items")) - Expect(req.SignAction).To(BeTrue()) - Expect(req.Headers).To(HaveKeyWithValue("Content-Type", "application/x-dmap-tagged")) - expectOwnedAppsHeaders(req.Headers, account, testGUID) - - payload, ok := req.Payload.(*http.RawPayload) - Expect(ok).To(BeTrue()) - sessionID, found, err := firstDMAPUint(payload.Content, "mlid") - Expect(err).ToNot(HaveOccurred()) - Expect(found).To(BeTrue()) - Expect(sessionID).To(Equal(uint64(testSessionID))) - revision, found, err := firstDMAPUint(payload.Content, "musr") - Expect(err).ToNot(HaveOccurred()) - Expect(found).To(BeTrue()) - Expect(revision).To(Equal(uint64(testRevision))) - }). - Return(http.Result[[]byte]{ - StatusCode: gohttp.StatusOK, - Data: ownedAppsDMAPResponse(apps), - }, nil) + expectStorefront := func(storefront string, data []byte) { + responses := []http.Result[[]byte]{ + {StatusCode: gohttp.StatusOK, Data: dmapTag("mlog", dmapUint32("mlid", testSessionID))}, + {StatusCode: gohttp.StatusOK, Data: dmapTag("mupd", dmapUint32("musr", testRevision))}, + {StatusCode: gohttp.StatusOK, Data: data}, + } + calls := make([]*gomock.Call, 0, len(responses)) + for _, response := range responses { + calls = append(calls, mockOwnedClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.Headers).To(HaveKeyWithValue("X-Apple-Store-Front", storefront)) + }).Return(response, nil)) + } + gomock.InOrder(calls[0], calls[1], calls[2]) } - When("the account has more than one page of apps", func() { - It("sorts all apps by descending purchase date before returning the requested page", func() { + DescribeTable("filters merged purchases before sorting and pagination", + func(platform Platform, page, total int, ids []int64) { expectMac() + item := func(id, kind, products, date uint32) []byte { + data := append(dmapUint32("aeSI", id), dmapUint32("aeMk", kind)...) + data = append(data, dmapUint32("aeSS", products)...) + data = append(data, dmapUint32("asdp", date)...) - responseOrder := []int{5, 12, 1, 9, 3, 11, 2, 8, 4, 10, 6, 7} - apps := make([]App, 0, 12) - for _, index := range responseOrder { - apps = append(apps, App{ - ID: int64(1000 + index), - BundleID: "com.example.app" + string(rune('a'+index-1)), - Name: "DAAP app", - Version: "1.0", - PurchaseDate: time.Unix(1_700_000_000+int64(index*60), 0).UTC(), - }) + return dmapTag("mlit", data) } + mobile := append(item(1, 131072, 1, 300), item(2, 131072, 19, 200)...) + mobile = append(mobile, item(3, 131072, 0, 100)...) + mac := append(item(2, 67108864, 0, 200), item(4, 67108864, 0, 400)...) + expectStorefront("143441-1,34", dmapTag("adbs", dmapTag("mlcl", mobile))) + expectStorefront("143441-1,13", dmapTag("adbs", dmapTag("mlcl", mac))) - loginCall := expectLogin() - updateCall := expectUpdate() - itemsCall := expectItems(apps) - gomock.InOrder(loginCall, updateCall, itemsCall) + output, err := as.OwnedApps(OwnedAppsInput{Account: account, Platform: platform, Page: page, Limit: 1}) - out, err := as.OwnedApps(OwnedAppsInput{Account: account, Page: 2, Limit: 10}) Expect(err).ToNot(HaveOccurred()) - Expect(out.TotalCount).To(Equal(12)) - Expect(out.Page).To(Equal(2)) - Expect(out.Count).To(Equal(2)) - Expect(out.Results).To(HaveLen(2)) - Expect(out.Results[0].ID).To(Equal(int64(1002))) - Expect(out.Results[1].ID).To(Equal(int64(1001))) - }) + Expect(output.TotalCount).To(Equal(total)) + Expect(output.Count).To(Equal(len(ids))) + Expect(output.Page).To(Equal(page)) + actualIDs := make([]int64, 0, len(output.Results)) + for _, app := range output.Results { + actualIDs = append(actualIDs, app.ID) + } + Expect(actualIDs).To(Equal(ids)) + if len(ids) == 1 && ids[0] == 2 { + Expect(output.Results[0].Platforms).To(Equal([]Platform{PlatformIPhone, PlatformIPad, PlatformVisionOS, PlatformMacOS})) + } + }, + Entry("no filter", Platform(""), 1, 4, []int64{4}), + Entry("iPhone", PlatformIPhone, 1, 2, []int64{1}), + Entry("iPad", PlatformIPad, 1, 1, []int64{2}), + Entry("visionOS", PlatformVisionOS, 1, 1, []int64{2}), + Entry("Mac second page includes merged platform", PlatformMacOS, 2, 2, []int64{2}), + Entry("no matching Apple TV apps", PlatformAppleTV, 1, 0, []int64{}), + Entry("page past filtered results", PlatformVisionOS, 2, 1, []int64{}), + ) + + It("puts a new Mac purchase first and merges apps from both storefronts", func() { + expectMac() + mobile := append(dmapUint32("aeSI", 123), dmapUint32("aeSS", 3)...) + mobile = append(mobile, dmapUint32("aeMk", 131072)...) + mobile = append(mobile, dmapUint32("asdp", 100)...) + arcade := append(dmapUint32("aeSI", 456), dmapUint32("aeMk", 262144)...) + arcade = append(arcade, dmapUint32("aeSS", 3)...) + mobileListing := append(dmapTag("mlit", mobile), dmapTag("mlit", arcade)...) + expectStorefront("143441-1,34", dmapTag("adbs", dmapTag("mlcl", mobileListing))) + + // SnippetsLab's Mac media kind and empty supported-products field + // reproduce the fields that were previously missing from the results. + mac := append(dmapUint32("aeSI", 1006087419), dmapUint32("aeMk", 67108864)...) + mac = append(mac, dmapTag("aeSS", []byte{0, 0})...) + mac = append(mac, dmapString("aeLN", "SnippetsLab")...) + mac = append(mac, dmapUint32("asdp", 300)...) + macListing := append(dmapTag("mlit", mobile), dmapTag("mlit", mac)...) + expectStorefront("143441-1,13", dmapTag("adbs", dmapTag("mlcl", macListing))) + + output, err := as.OwnedApps(OwnedAppsInput{Account: account, Limit: 2}) + Expect(err).ToNot(HaveOccurred()) + Expect(output.TotalCount).To(Equal(3)) + Expect(output.Count).To(Equal(2)) + Expect(output.Results[0].Name).To(Equal("SnippetsLab")) + Expect(output.Results[0].Platforms).To(Equal([]Platform{PlatformMacOS})) + Expect(output.Results[1].ID).To(Equal(int64(123))) + Expect(output.Results[1].Platforms).To(Equal([]Platform{PlatformIPhone, PlatformIPad})) + }) + + It("does not infer a platform from the storefront or app name", func() { + expectMac() + expectStorefront("143441-1,34", dmapTag("adbs", nil)) + item := append(dmapUint32("aeSI", 123), dmapString("aeLN", "Example for Mac")...) + expectStorefront("143441-1,13", dmapTag("adbs", dmapTag("mlcl", dmapTag("mlit", item)))) + + output, err := as.OwnedApps(OwnedAppsInput{Account: account}) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Results).To(HaveLen(1)) + Expect(output.Results[0].Platforms).To(Equal([]Platform{PlatformUnknown})) + }) + It("does not return an incomplete list if the Mac storefront fails", func() { + expectMac() + expectStorefront("143441-1,34", ownedAppsDMAPResponse([]App{{ID: 123}})) + mockOwnedClient.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: gohttp.StatusUnauthorized}, nil) + + output, err := as.OwnedApps(OwnedAppsInput{Account: account}) + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeTrue()) + Expect(output.Results).To(BeEmpty()) + }) + + When("the account has more than one page of apps", func() { It("returns every app when All is set", func() { expectMac() @@ -161,7 +175,8 @@ var _ = Describe("AppStore (OwnedApps)", func() { {ID: 4, BundleID: "d", Name: "D", PurchaseDate: time.Unix(200, 0).UTC()}, } - gomock.InOrder(expectLogin(), expectUpdate(), expectItems(apps)) + expectStorefront("143441-1,34", ownedAppsDMAPResponse(apps)) + expectStorefront("143441-1,13", ownedAppsDMAPResponse(nil)) out, err := as.OwnedApps(OwnedAppsInput{Account: account, All: true}) Expect(err).ToNot(HaveOccurred()) @@ -171,6 +186,106 @@ var _ = Describe("AppStore (OwnedApps)", func() { // newest first; apps without a purchase date go last Expect(ids).To(Equal([]int64{2, 4, 1, 3})) }) + + It("sorts all apps by descending purchase date before returning the requested partial page", func() { + expectMac() + + responseOrder := []int{5, 12, 1, 9, 3, 11, 2, 8, 4, 10, 6, 7} + apps := make([]App, 0, 12) + for _, index := range responseOrder { + apps = append(apps, App{ + ID: int64(1000 + index), + BundleID: "com.example.app" + string(rune('a'+index-1)), + Name: "DAAP app", + Version: "1.0", + PurchaseDate: time.Unix(1_700_000_000+int64(index*60), 0).UTC(), + }) + } + + loginCall := mockOwnedClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.Method).To(Equal(http.MethodPOST)) + Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/login")) + Expect(req.SignAction).To(BeFalse()) + Expect(req.Payload).To(BeNil()) + expectOwnedAppsHeaders(req.Headers, account, testGUID) + }). + Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: dmapTag("mlog", dmapUint32("mlid", testSessionID)), + }, nil) + + updateCall := mockOwnedClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.Method).To(Equal(http.MethodPOST)) + Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/update")) + Expect(req.SignAction).To(BeTrue()) + Expect(req.Headers).To(HaveKeyWithValue("Content-Type", "application/x-www-form-urlencoded")) + expectOwnedAppsHeaders(req.Headers, account, testGUID) + + payload, ok := req.Payload.(*http.RawPayload) + Expect(ok).To(BeTrue()) + Expect(string(payload.Content)).To(Equal("session-id=42&revision-number=(null)&query=('com.apple.itunes.extended\\-media\\-kind:131072','com.apple.itunes.extended\\-media\\-kind:262144','com.apple.itunes.extended\\-media\\-kind:67108864')")) + }). + Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: dmapTag("mupd", dmapUint32("musr", testRevision)), + }, nil) + + itemsCall := mockOwnedClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + Expect(req.Method).To(Equal(http.MethodPOST)) + Expect(req.URL).To(Equal(PrivatePurchaseDAAPBaseURL + "/databases/99/items")) + Expect(req.SignAction).To(BeTrue()) + Expect(req.Headers).To(HaveKeyWithValue("Content-Type", "application/x-dmap-tagged")) + expectOwnedAppsHeaders(req.Headers, account, testGUID) + + payload, ok := req.Payload.(*http.RawPayload) + Expect(ok).To(BeTrue()) + sessionID, found, err := firstDMAPUint(payload.Content, "mlid") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(sessionID).To(Equal(uint64(testSessionID))) + revision, found, err := firstDMAPUint(payload.Content, "musr") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(revision).To(Equal(uint64(testRevision))) + err = walkDMAP(payload.Content, 0, func(tag string, data []byte) error { + if tag == "mque" { + Expect(string(data)).To(Equal("('com.apple.itunes.extended\\-media\\-kind:131072','com.apple.itunes.extended\\-media\\-kind:262144','com.apple.itunes.extended\\-media\\-kind:67108864')")) + } + + return nil + }) + Expect(err).ToNot(HaveOccurred()) + }). + Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: ownedAppsDMAPResponse(apps), + }, nil) + + gomock.InOrder(loginCall, updateCall, itemsCall) + expectStorefront("143441-1,13", ownedAppsDMAPResponse(nil)) + + output, err := as.OwnedApps(OwnedAppsInput{ + Account: account, + Page: 2, + Limit: 10, + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Count).To(Equal(2)) + Expect(output.TotalCount).To(Equal(12)) + Expect(output.Page).To(Equal(2)) + Expect(output.Results).To(HaveLen(2)) + Expect(output.Results[0].ID).To(Equal(int64(1002))) + Expect(output.Results[0].PurchaseDate).To(Equal(time.Unix(1_700_000_120, 0).UTC())) + Expect(output.Results[1].ID).To(Equal(int64(1001))) + Expect(output.Results[1].PurchaseDate).To(Equal(time.Unix(1_700_000_060, 0).UTC())) + }) }) When("the response repeats an app", func() { @@ -182,7 +297,8 @@ var _ = Describe("AppStore (OwnedApps)", func() { {ID: 7, BundleID: "x", Name: "X", PurchaseDate: time.Unix(100, 0).UTC()}, } - gomock.InOrder(expectLogin(), expectUpdate(), expectItems(apps)) + expectStorefront("143441-1,34", ownedAppsDMAPResponse(apps)) + expectStorefront("143441-1,13", ownedAppsDMAPResponse(nil)) out, err := as.OwnedApps(OwnedAppsInput{Account: account, All: true}) Expect(err).ToNot(HaveOccurred()) @@ -190,18 +306,56 @@ var _ = Describe("AppStore (OwnedApps)", func() { }) }) - When("the DAAP login answers with an unauthorized status", func() { - It("returns ErrPasswordTokenExpired", func() { + When("the requested page is past the end", func() { + It("returns an empty page without a metadata lookup", func() { expectMac() + gomock.InOrder( + mockOwnedClient.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: dmapTag("mlog", dmapUint32("mlid", testSessionID)), + }, nil), + mockOwnedClient.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: dmapTag("mupd", dmapUint32("musr", testRevision)), + }, nil), + mockOwnedClient.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{ + StatusCode: gohttp.StatusOK, + Data: ownedAppsDMAPResponse([]App{{ID: 1}}), + }, nil), + ) + + expectStorefront("143441-1,13", ownedAppsDMAPResponse(nil)) + + output, err := as.OwnedApps(OwnedAppsInput{Account: account, Page: 2, Limit: 10}) + + Expect(err).ToNot(HaveOccurred()) + Expect(output.Count).To(Equal(0)) + Expect(output.TotalCount).To(Equal(1)) + Expect(output.Results).To(BeEmpty()) + }) + }) + + When("the authenticated session is rejected", func() { + It("returns the password-token-expired error", func() { + expectMac() mockOwnedClient.EXPECT(). Send(gomock.Any()). - Return(http.Result[[]byte]{ - StatusCode: gohttp.StatusUnauthorized, - }, nil) + Return(http.Result[[]byte]{StatusCode: gohttp.StatusUnauthorized}, nil) _, err := as.OwnedApps(OwnedAppsInput{Account: account}) - Expect(err).To(MatchError(ErrPasswordTokenExpired)) + + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeTrue()) + }) + }) + + When("the machine address cannot be read", func() { + It("returns an error before fetching purchase history", func() { + mockMachine.EXPECT().MacAddress().Return("", errors.New("machine error")) + + _, err := as.OwnedApps(OwnedAppsInput{Account: account}) + + Expect(err).To(MatchError(ContainSubstring("failed to get mac address"))) }) }) @@ -234,29 +388,6 @@ var _ = Describe("AppStore (OwnedApps)", func() { }) }) - When("the MAC address cannot be resolved", func() { - It("returns error", func() { - mockMachine.EXPECT(). - MacAddress(). - Return("", errors.New("")) - - _, err := as.OwnedApps(OwnedAppsInput{Account: account}) - Expect(err).To(HaveOccurred()) - }) - }) - - When("input is invalid", func() { - It("rejects a negative page", func() { - _, err := as.OwnedApps(OwnedAppsInput{Account: account, Page: -1}) - Expect(err).To(HaveOccurred()) - }) - - It("rejects a limit above the maximum", func() { - _, err := as.OwnedApps(OwnedAppsInput{Account: account, Limit: MaxOwnedAppsLimit + 1}) - Expect(err).To(HaveOccurred()) - }) - }) - Describe("walkDMAP", func() { It("rejects truncated payloads", func() { data := dmapTag("mlog", dmapUint32("mlid", 1)) @@ -273,6 +404,217 @@ var _ = Describe("AppStore (OwnedApps)", func() { Expect(err).To(HaveOccurred()) }) }) + + DescribeTable("validates and defaults pagination", + func(input OwnedAppsInput, expected OwnedAppsInput, errorText string) { + actual, err := normalizeOwnedAppsInput(input) + if errorText != "" { + Expect(err).To(MatchError(ContainSubstring(errorText))) + + return + } + + Expect(err).ToNot(HaveOccurred()) + Expect(actual).To(Equal(expected)) + }, + Entry("defaults page and limit", OwnedAppsInput{}, OwnedAppsInput{Page: 1, Limit: 10}, ""), + Entry("accepts the maximum limit", OwnedAppsInput{Page: 3, Limit: 100}, OwnedAppsInput{Page: 3, Limit: 100}, ""), + Entry("rejects a negative page", OwnedAppsInput{Page: -1, Limit: 10}, OwnedAppsInput{}, "page"), + Entry("rejects a negative limit", OwnedAppsInput{Page: 1, Limit: -1}, OwnedAppsInput{}, "limit"), + Entry("rejects a limit over 100", OwnedAppsInput{Page: 1, Limit: 101}, OwnedAppsInput{}, "100"), + Entry("rejects an invalid platform", OwnedAppsInput{Platform: "invalid"}, OwnedAppsInput{}, "invalid platform"), + Entry("rejects unknown as a filter", OwnedAppsInput{Platform: PlatformUnknown}, OwnedAppsInput{}, "invalid platform"), + Entry("all overrides page and limit", OwnedAppsInput{All: true, Page: 3, Limit: 5}, OwnedAppsInput{All: true, Page: 1, Limit: MaxOwnedAppsLimit}, ""), + ) +}) + +var _ = Describe("owned-app DMAP parsing", func() { + It("parses app fields, ignores unknown fields, and removes duplicate IDs", func() { + item := make([]byte, 0, 128) + item = append(item, dmapString("zzzz", "unknown")...) + item = append(item, dmapAppIDForTest(123)...) + item = append(item, dmapString("aeBI", "com.example.app")...) + item = append(item, dmapString("aeLN", "Example")...) + item = append(item, dmapString("aePd", "1.2.3")...) + item = append(item, dmapUint32("asdp", 1_700_000_000)...) + listing := append(dmapTag("mlit", item), dmapTag("mlit", item)...) + response := dmapTag("adbs", dmapTag("mlcl", listing)) + + apps, err := parseOwnedApps(response) + + Expect(err).ToNot(HaveOccurred()) + Expect(apps).To(Equal([]App{{ + ID: 123, + BundleID: "com.example.app", + Name: "Example", + Version: "1.2.3", + PurchaseDate: time.Unix(1_700_000_000, 0).UTC(), + Platforms: []Platform{PlatformUnknown}, + }})) + }) + + DescribeTable("decodes supported platforms", + func(kind uint32, products []byte, expected []Platform) { + item := append(dmapUint32("aeMk", kind), dmapTag("aeSS", products)...) + app, err := parseOwnedApp(item) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Platforms).To(Equal(expected)) + }, + Entry("iPhone", uint32(131072), []byte{0, 1}, []Platform{PlatformIPhone}), + Entry("iPad", uint32(131072), []byte{0, 2}, []Platform{PlatformIPad}), + Entry("Mac universal purchase", uint32(131072), []byte{0, 8}, []Platform{PlatformMacOS}), + Entry("visionOS", uint32(131072), []byte{0, 16}, []Platform{PlatformVisionOS}), + Entry("Arcade does not imply Mac support", uint32(262144), []byte{0, 3}, []Platform{PlatformIPhone, PlatformIPad}), + Entry("universal app", uint32(131072), []byte{0, 27}, []Platform{PlatformIPhone, PlatformIPad, PlatformVisionOS, PlatformMacOS}), + Entry("Mac ignores mobile product bits", uint32(67108864), []byte{0, 7}, []Platform{PlatformMacOS}), + Entry("iPod touch only", uint32(131072), []byte{0, 4}, []Platform{PlatformUnknown}), + Entry("unknown product bit does not imply visionOS", uint32(131072), []byte{0, 32}, []Platform{PlatformUnknown}), + Entry("empty products", uint32(131072), []byte{0, 0}, []Platform{PlatformUnknown}), + Entry("unknown media kind", uint32(999), []byte{0, 31}, []Platform{PlatformUnknown}), + Entry("unknown products", uint32(131072), []byte{128, 0}, []Platform{PlatformUnknown}), + Entry("one-byte products", uint32(131072), []byte{3}, []Platform{PlatformIPhone, PlatformIPad}), + Entry("four-byte products", uint32(131072), []byte{0, 0, 0, 3}, []Platform{PlatformIPhone, PlatformIPad}), + Entry("eight-byte products", uint32(131072), []byte{0, 0, 0, 0, 0, 0, 0, 3}, []Platform{PlatformIPhone, PlatformIPad}), + ) + + DescribeTable("returns unknown for missing platform metadata", + func(item []byte) { + app, err := parseOwnedApp(item) + Expect(err).ToNot(HaveOccurred()) + Expect(app.Platforms).To(Equal([]Platform{PlatformUnknown})) + }, + Entry("missing products", dmapUint32("aeMk", 131072)), + Entry("missing media kind", dmapUint32("aeSS", 31)), + ) + + DescribeTable("replaces unknown when another record identifies the platform", + func(platforms ...[]Platform) { + records := make([]App, 0, len(platforms)) + for _, value := range platforms { + records = append(records, App{ID: 123, Platforms: value}) + } + apps := mergeOwnedApps(records) + Expect(apps).To(HaveLen(1)) + Expect(apps[0].Platforms).To(Equal([]Platform{PlatformMacOS})) + }, + Entry("unknown first", []Platform{PlatformUnknown}, []Platform{PlatformMacOS}), + Entry("unknown last", []Platform{PlatformMacOS}, []Platform{PlatformUnknown}), + ) + + It("identifies YouTube for visionOS as visionOS only", func() { + item := dmapAppIDForTest(6745572359) + item = append(item, dmapString("aeBI", "com.google.visionosyoutube")...) + item = append(item, dmapString("aeLN", "YouTube for visionOS")...) + item = append(item, dmapUint32("aeMk", 131072)...) + item = append(item, dmapTag("aeSS", []byte{0, 16})...) + + apps, err := parseOwnedApps(dmapTag("adbs", dmapTag("mlcl", dmapTag("mlit", item)))) + + Expect(err).ToNot(HaveOccurred()) + Expect(apps).To(HaveLen(1)) + Expect(apps[0].ID).To(Equal(int64(6745572359))) + Expect(apps[0].Platforms).To(Equal([]Platform{PlatformVisionOS})) + }) + + It("merges platforms and keeps the latest purchase date before pagination", func() { + mobile := append(dmapUint32("aeSI", 123), dmapUint32("aeSS", 3)...) + mobile = append(mobile, dmapUint32("aeMk", 131072)...) + mobile = append(mobile, dmapUint32("asdp", 100)...) + mac := append(dmapUint32("aeSI", 123), dmapUint32("aeMk", 67108864)...) + mac = append(mac, dmapUint32("asdp", 300)...) + other := append(dmapUint32("aeSI", 456), dmapUint32("asdp", 200)...) + listing := append(dmapTag("mlit", mobile), dmapTag("mlit", other)...) + listing = append(listing, dmapTag("mlit", mac)...) + listing = append(listing, dmapTag("mlit", mobile)...) + + apps, err := parseOwnedApps(dmapTag("adbs", dmapTag("mlcl", listing))) + Expect(err).ToNot(HaveOccurred()) + Expect(apps).To(HaveLen(2)) + page := ownedAppsPage(ownedAppsSortedByPurchaseDate(apps), 1, 1) + Expect(page[0].ID).To(Equal(int64(123))) + Expect(page[0].Platforms).To(Equal([]Platform{PlatformIPhone, PlatformIPad, PlatformMacOS})) + Expect(page[0].PurchaseDate).To(Equal(time.Unix(300, 0).UTC())) + }) + + DescribeTable("rejects malformed platform metadata", + func(tag string) { + _, err := parseOwnedApp(dmapTag(tag, []byte{1, 2, 3})) + Expect(err).To(MatchError(ContainSubstring("invalid integer length 3"))) + }, + Entry("media kind", "aeMk"), + Entry("supported products", "aeSS"), + ) + + It("rejects a malformed purchase date", func() { + item := append(dmapAppIDForTest(123), dmapTag("asdp", []byte{1, 2, 3})...) + response := dmapTag("adbs", dmapTag("mlcl", dmapTag("mlit", item))) + + _, err := parseOwnedApps(response) + + Expect(err).To(MatchError(ContainSubstring("purchase date has invalid length 3"))) + }) + + It("rejects a truncated tag", func() { + _, err := parseOwnedApps([]byte("adbs\x00\x00\x00\x10short")) + + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("exceeds remaining response length")) + }) + + It("rejects a protocol-level error status", func() { + response := dmapTag("mlog", dmapUint32("mstt", gohttp.StatusForbidden)) + + err := ownedAppsDMAPStatusError("purchase history login", response) + + Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrPasswordTokenExpired)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("DAAP status 403")) + }) + + It("preserves a partial last page", func() { + apps := []App{{ID: 1}, {ID: 2}, {ID: 3}} + + Expect(ownedAppsPage(apps, 2, 2)).To(Equal([]App{{ID: 3}})) + Expect(ownedAppsPage(apps, 3, 2)).To(BeEmpty()) + Expect(ownedAppsPage(apps, math.MaxInt, 100)).To(BeEmpty()) + }) + + It("sorts missing dates last and preserves response order for ties", func() { + date := time.Unix(1_700_000_000, 0).UTC() + apps := []App{ + {ID: 1}, + {ID: 2, PurchaseDate: date}, + {ID: 3, PurchaseDate: date.Add(time.Hour)}, + {ID: 4, PurchaseDate: date}, + {ID: 5}, + } + + sorted := ownedAppsSortedByPurchaseDate(apps) + + Expect(sorted).To(Equal([]App{ + {ID: 3, PurchaseDate: date.Add(time.Hour)}, + {ID: 2, PurchaseDate: date}, + {ID: 4, PurchaseDate: date}, + {ID: 1}, + {ID: 5}, + })) + Expect(apps[0].ID).To(Equal(int64(1))) + }) + + It("builds the dynamic DMAP item request body", func() { + now := time.Unix(1_700_000_000, 0) + query := "('com.apple.itunes.extended\\-media\\-kind:131072')" + body := ownedAppsItemsBody(42, 99, query, now) + + timestamp, found, err := firstDMAPUint(body, "mstc") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(timestamp).To(Equal(uint64(1_700_000_000))) + sessionID, found, err := firstDMAPUint(body, "mlid") + Expect(err).ToNot(HaveOccurred()) + Expect(found).To(BeTrue()) + Expect(sessionID).To(Equal(uint64(42))) + }) }) func expectOwnedAppsHeaders(headers map[string]string, account Account, guid string) { @@ -290,7 +632,7 @@ func ownedAppsDMAPResponse(apps []App) []byte { for _, app := range apps { item := make([]byte, 0, 128) - item = append(item, dmapUint64ForTest("aeSI", uint64(app.ID))...) + item = append(item, dmapAppIDForTest(uint64(app.ID))...) item = append(item, dmapString("aeBI", app.BundleID)...) item = append(item, dmapString("aeLN", app.Name)...) item = append(item, dmapString("aePd", app.Version)...) @@ -305,9 +647,9 @@ func ownedAppsDMAPResponse(apps []App) []byte { return dmapTag("adbs", dmapTag("mlcl", listing)) } -func dmapUint64ForTest(name string, value uint64) []byte { +func dmapAppIDForTest(value uint64) []byte { payload := make([]byte, 8) binary.BigEndian.PutUint64(payload, value) - return dmapTag(name, payload) + return dmapTag("aeSI", payload) } diff --git a/pkg/appstore/appstore_replicate_sinf.go b/pkg/appstore/appstore_replicate_sinf.go index e4ee2f0..21f919c 100644 --- a/pkg/appstore/appstore_replicate_sinf.go +++ b/pkg/appstore/appstore_replicate_sinf.go @@ -48,7 +48,7 @@ func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error { zipWriter := zip.NewWriter(tmpFile) - err = t.replicateZip(zipReader, zipWriter) + err = t.replicateZip(zipReader, zipWriter, input.PackagePath) if err != nil { return fmt.Errorf("failed to replicate zip: %w", err) } @@ -239,31 +239,60 @@ func (t *appstore) replicateSinfFromInfo(info packageInfo, zip *zip.Writer, sinf return nil } -func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer) error { +func (t *appstore) replicateZip(src *zip.ReadCloser, dst *zip.Writer, sourcePath string) error { + source, err := os.Open(sourcePath) + if err != nil { + return fmt.Errorf("failed to open zip headers: %w", err) + } + + defer source.Close() + + info, err := source.Stat() + if err != nil { + return fmt.Errorf("failed to stat zip: %w", err) + } + + headers, err := newZIPLocalHeaders(source, info.Size()) + if err != nil { + return fmt.Errorf("failed to read zip directory: %w", err) + } + for _, file := range src.File { - err := func() error { - srcFile, err := file.Open() - if err != nil { - return fmt.Errorf("failed to open file: %w", err) - } - defer srcFile.Close() - header := file.FileHeader - dstFile, err := dst.CreateHeader(&header) + srcFile, err := file.OpenRaw() + if err != nil { + return fmt.Errorf("failed to open file: %w", err) + } - if err != nil { - return fmt.Errorf("failed to create file: %w", err) - } + header := file.FileHeader - _, err = io.Copy(dstFile, srcFile) - if err != nil { - return fmt.Errorf("failed to copy file: %w", err) - } + localExtra, err := headers.extra(file) + if err != nil { + return fmt.Errorf("failed to read local metadata for %q: %w", file.Name, err) + } + + header.Extra = zipStoredExtra(&header, localExtra) + // Frame deflated files with a trailing descriptor for streaming + // installers, without changing their compressed data or timestamps. + // Stored files need inline sizes because they have no stream terminator. + if strings.HasSuffix(header.Name, "/") || header.Method == zip.Store { + header.Flags &^= 0x8 + } else if header.Method == zip.Deflate { + header.Flags |= 0x8 + } + + dstFile, err := dst.CreateRaw(&header) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + // CreateRaw writes the local header immediately and retains this pointer + // for the central directory written by Close. Keep the two extras distinct. + header.Extra = zipExtraWithoutZIP64(file.Extra) - return nil - }() + _, err = io.Copy(dstFile, srcFile) if err != nil { - return err + return fmt.Errorf("failed to copy file: %w", err) } } diff --git a/pkg/appstore/appstore_zip_framing_test.go b/pkg/appstore/appstore_zip_framing_test.go new file mode 100644 index 0000000..c49f3b9 --- /dev/null +++ b/pkg/appstore/appstore_zip_framing_test.go @@ -0,0 +1,124 @@ +package appstore + +import ( + "archive/zip" + "bytes" + "compress/flate" + "encoding/binary" + "hash/crc32" + "io" + "os" + "path/filepath" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AppStore (ZIP streaming framing)", func() { + DescribeTable("preserves entry data and metadata with streaming-compatible framing", + func(name string, method, sourceFlags uint16) { + payload := []byte("streaming archive test payload") + if strings.HasSuffix(name, "/") { + payload = []byte{} + } + raw := payload + if method == zip.Deflate { + var compressed bytes.Buffer + compressor, err := flate.NewWriter(&compressed, flate.HuffmanOnly) + Expect(err).ToNot(HaveOccurred()) + _, err = compressor.Write(payload) + Expect(err).ToNot(HaveOccurred()) + Expect(compressor.Close()).To(Succeed()) + raw = compressed.Bytes() + } + + By("creating an archive with the requested source framing") + var source bytes.Buffer + sourceWriter := zip.NewWriter(&source) + writer, err := sourceWriter.CreateRaw(&zip.FileHeader{ + Name: name, + Method: method, + Flags: sourceFlags, + CRC32: crc32.ChecksumIEEE(payload), + CompressedSize64: uint64(len(raw)), + UncompressedSize64: uint64(len(payload)), + ModifiedDate: 0x5821, + ModifiedTime: 0x1234, + ExternalAttrs: 0x81a40000, + }) + Expect(err).ToNot(HaveOccurred()) + _, err = writer.Write(raw) + Expect(err).ToNot(HaveOccurred()) + Expect(sourceWriter.Close()).To(Succeed()) + + directory, err := os.MkdirTemp("", "ipatool-zip-framing-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(os.RemoveAll, directory) + path := filepath.Join(directory, "source.zip") + Expect(os.WriteFile(path, source.Bytes(), 0600)).To(Succeed()) + src, err := zip.OpenReader(path) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(src.Close) + + var output bytes.Buffer + dst := zip.NewWriter(&output) + Expect((&appstore{}).replicateZip(src, dst, path)).To(Succeed()) + Expect(dst.Close()).To(Succeed()) + result, err := zip.NewReader(bytes.NewReader(output.Bytes()), int64(output.Len())) + Expect(err).ToNot(HaveOccurred()) + Expect(result.File).To(HaveLen(1)) + file := result.File[0] + original := src.File[0] + + By("checking local framing independently of the central directory") + offset, err := file.DataOffset() + Expect(err).ToNot(HaveOccurred()) + local := output.Bytes()[:offset] + flags := binary.LittleEndian.Uint16(local[6:8]) + Expect(flags).To(Equal(file.Flags)) + end := offset + int64(file.CompressedSize64) + if method == zip.Deflate { + Expect(flags & 8).To(Equal(uint16(8))) + Expect(local[14:26]).To(Equal(make([]byte, 12))) + descriptor := output.Bytes()[end : end+16] + Expect(binary.LittleEndian.Uint32(descriptor)).To(Equal(uint32(0x08074b50))) + Expect(binary.LittleEndian.Uint32(descriptor[4:])).To(Equal(file.CRC32)) + Expect(binary.LittleEndian.Uint32(descriptor[8:])).To(Equal(uint32(file.CompressedSize64))) + Expect(binary.LittleEndian.Uint32(descriptor[12:])).To(Equal(uint32(file.UncompressedSize64))) + } else { + Expect(flags & 8).To(BeZero()) + Expect(binary.LittleEndian.Uint32(local[14:])).To(Equal(file.CRC32)) + Expect(binary.LittleEndian.Uint32(local[18:])).To(Equal(uint32(file.CompressedSize64))) + Expect(binary.LittleEndian.Uint32(local[22:])).To(Equal(uint32(file.UncompressedSize64))) + Expect(binary.LittleEndian.Uint32(output.Bytes()[end:])).To(Equal(uint32(0x02014b50))) + } + + By("preserving the compressed bytes and metadata") + reader, err := file.OpenRaw() + Expect(err).ToNot(HaveOccurred()) + copied, err := io.ReadAll(reader) + Expect(err).ToNot(HaveOccurred()) + Expect(copied).To(Equal(raw)) + Expect(file.Name).To(Equal(original.Name)) + Expect(file.Method).To(Equal(original.Method)) + Expect(file.ModifiedDate).To(Equal(original.ModifiedDate)) + Expect(file.ModifiedTime).To(Equal(original.ModifiedTime)) + Expect(file.ExternalAttrs).To(Equal(original.ExternalAttrs)) + Expect(file.Extra).To(Equal(original.Extra)) + + By("reading the content back with CRC verification") + contentReader, err := file.Open() + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(contentReader.Close) + content, err := io.ReadAll(contentReader) + Expect(err).ToNot(HaveOccurred()) + Expect(content).To(Equal(payload)) + }, + Entry("directory", "Payload/", zip.Store, uint16(0)), + Entry("stored file without a descriptor", "stored", zip.Store, uint16(0)), + Entry("stored file with an existing descriptor", "stored-descriptor", zip.Store, uint16(8)), + Entry("deflated file without a descriptor", "deflated", zip.Deflate, uint16(0)), + Entry("deflated file with an existing descriptor", "deflated-descriptor", zip.Deflate, uint16(8)), + ) +}) diff --git a/pkg/appstore/appstore_zip_headers.go b/pkg/appstore/appstore_zip_headers.go new file mode 100644 index 0000000..0935760 --- /dev/null +++ b/pkg/appstore/appstore_zip_headers.go @@ -0,0 +1,289 @@ +package appstore + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "fmt" + "io" + "math" +) + +const ( + zipLocalHeaderSignature = 0x04034b50 + zipCentralHeaderSignature = 0x02014b50 + zipDirectoryEndSignature = 0x06054b50 + zip64DirectoryEndSignature = 0x06064b50 + zip64LocatorSignature = 0x07064b50 + zip64ExtraID = 0x0001 + + zipLocalHeaderLen = 30 + zipCentralHeaderLen = 46 + zipDirectoryEndLen = 22 + zip64DirectoryEndLen = 56 + zip64LocatorLen = 20 +) + +// zipLocalHeaders reads the local metadata that archive/zip's FileHeader omits. +// The central directory provides offsets rather than relying on entry order or +// searching compressed data for local-header signatures. +type zipLocalHeaders struct { + source io.ReaderAt + directory *io.SectionReader + base int64 +} + +func newZIPLocalHeaders(source io.ReaderAt, size int64) (*zipLocalHeaders, error) { + directory, err := readZIPDirectoryEnd(source, size) + if err != nil { + return nil, err + } + + if directory.size > uint64(directory.endOffset) || directory.offset > math.MaxInt64 { + return nil, zip.ErrFormat + } + + base := directory.endOffset - int64(directory.size) - int64(directory.offset) + // Match archive/zip's handling of prefixed archives and inaccurate directory + // sizes: prefer the declared offset if it already points to a central header. + var signature [4]byte + if base > 0 { + if _, err := source.ReadAt(signature[:], int64(directory.offset)); err == nil && binary.LittleEndian.Uint32(signature[:]) == zipCentralHeaderSignature { + base = 0 + } + } + + if base < 0 || base > size-int64(directory.offset) { + return nil, zip.ErrFormat + } + + start := base + int64(directory.offset) + + return &zipLocalHeaders{ + source: source, + directory: io.NewSectionReader(source, start, size-start), + base: base, + }, nil +} + +type zipDirectoryEnd struct { + offset uint64 + size uint64 + endOffset int64 +} + +func readZIPDirectoryEnd(source io.ReaderAt, size int64) (zipDirectoryEnd, error) { + // The end record can be followed by a comment of up to 65535 bytes. + tail := make([]byte, min(size, zipDirectoryEndLen+math.MaxUint16)) + if _, err := source.ReadAt(tail, size-int64(len(tail))); err != nil { + return zipDirectoryEnd{}, fmt.Errorf("failed to read zip trailer: %w", err) + } + + for i := len(tail) - zipDirectoryEndLen; i >= 0; i-- { + if binary.LittleEndian.Uint32(tail[i:]) != zipDirectoryEndSignature { + continue + } + + commentLen := int(binary.LittleEndian.Uint16(tail[i+20:])) + if i+zipDirectoryEndLen+commentLen > len(tail) { + continue + } + + directory := zipDirectoryEnd{ + size: uint64(binary.LittleEndian.Uint32(tail[i+12:])), + offset: uint64(binary.LittleEndian.Uint32(tail[i+16:])), + endOffset: size - int64(len(tail)) + int64(i), + } + + return readZIP64DirectoryEnd(source, size, directory) + } + + return zipDirectoryEnd{}, zip.ErrFormat +} + +func readZIP64DirectoryEnd(source io.ReaderAt, size int64, directory zipDirectoryEnd) (zipDirectoryEnd, error) { + // A ZIP64 locator, when present, immediately precedes the ordinary end record. + if directory.endOffset < zip64LocatorLen { + return directory, nil + } + + var locator [zip64LocatorLen]byte + if _, err := source.ReadAt(locator[:], directory.endOffset-zip64LocatorLen); err != nil { + return zipDirectoryEnd{}, fmt.Errorf("failed to read zip64 locator: %w", err) + } + + if binary.LittleEndian.Uint32(locator[:]) != zip64LocatorSignature { + return directory, nil + } + + offset := binary.LittleEndian.Uint64(locator[8:]) + disk := binary.LittleEndian.Uint32(locator[4:]) + diskCount := binary.LittleEndian.Uint32(locator[16:]) + + if offset > uint64(size) || disk != 0 || diskCount != 1 { + return zipDirectoryEnd{}, zip.ErrFormat + } + + var end [zip64DirectoryEndLen]byte + if _, err := source.ReadAt(end[:], int64(offset)); err != nil { + return zipDirectoryEnd{}, fmt.Errorf("failed to read zip64 trailer: %w", err) + } + + if binary.LittleEndian.Uint32(end[:]) != zip64DirectoryEndSignature { + return zipDirectoryEnd{}, zip.ErrFormat + } + + return zipDirectoryEnd{ + size: binary.LittleEndian.Uint64(end[40:]), + offset: binary.LittleEndian.Uint64(end[48:]), + endOffset: int64(offset), + }, nil +} + +func (r *zipLocalHeaders) extra(file *zip.File) ([]byte, error) { + var central [zipCentralHeaderLen]byte + if _, err := io.ReadFull(r.directory, central[:]); err != nil { + return nil, fmt.Errorf("failed to read central header: %w", err) + } + + if binary.LittleEndian.Uint32(central[:]) != zipCentralHeaderSignature { + return nil, zip.ErrFormat + } + + nameLen := int(binary.LittleEndian.Uint16(central[28:])) + extraLen := int(binary.LittleEndian.Uint16(central[30:])) + commentLen := int(binary.LittleEndian.Uint16(central[32:])) + + metadata := make([]byte, nameLen+extraLen+commentLen) + if _, err := io.ReadFull(r.directory, metadata); err != nil { + return nil, fmt.Errorf("failed to read central metadata: %w", err) + } + + if string(metadata[:nameLen]) != file.Name { + return nil, zip.ErrFormat + } + + offset, err := zipLocalHeaderOffset(central[:], metadata[nameLen:nameLen+extraLen]) + if err != nil { + return nil, err + } + + if offset > uint64(math.MaxInt64-r.base) { + return nil, zip.ErrFormat + } + + localOffset := r.base + int64(offset) + + var local [zipLocalHeaderLen]byte + + if _, err := r.source.ReadAt(local[:], localOffset); err != nil { + return nil, fmt.Errorf("failed to read local header: %w", err) + } + + if binary.LittleEndian.Uint32(local[:]) != zipLocalHeaderSignature { + return nil, zip.ErrFormat + } + + localNameLen := int(binary.LittleEndian.Uint16(local[26:])) + localExtraLen := int(binary.LittleEndian.Uint16(local[28:])) + + dataOffset, err := file.DataOffset() + if err != nil { + return nil, fmt.Errorf("failed to locate entry data: %w", err) + } + + if dataOffset-localOffset != int64(zipLocalHeaderLen+localNameLen+localExtraLen) { + return nil, zip.ErrFormat + } + + localMetadata := make([]byte, localNameLen+localExtraLen) + if _, err := r.source.ReadAt(localMetadata, localOffset+zipLocalHeaderLen); err != nil { + return nil, fmt.Errorf("failed to read local metadata: %w", err) + } + + if !bytes.Equal(localMetadata[:localNameLen], metadata[:nameLen]) { + return nil, zip.ErrFormat + } + + return localMetadata[localNameLen:], nil +} + +// ZIP64 central-directory offsets belong to the source archive. Let zip.Writer +// regenerate structural ZIP64 values for the output instead of leaving an old +// block ahead of the one it appends. Preserve all other extra bytes verbatim. +func zipExtraWithoutZIP64(extra []byte) []byte { + result := make([]byte, 0, len(extra)) + + for len(extra) >= 4 { + length := int(binary.LittleEndian.Uint16(extra[2:])) + 4 + if length > len(extra) { + break + } + + if binary.LittleEndian.Uint16(extra) != zip64ExtraID { + result = append(result, extra[:length]...) + } + + extra = extra[length:] + } + + return append(result, extra...) +} + +// Stored ZIP64 entries have no descriptor after framing normalization, so their +// local ZIP64 sizes must be authoritative even if the source used placeholders. +func zipStoredExtra(header *zip.FileHeader, extra []byte) []byte { + if header.Method != zip.Store || (header.CompressedSize64 < math.MaxUint32 && header.UncompressedSize64 < math.MaxUint32) { + return extra + } + + result := zipExtraWithoutZIP64(extra) + + var sizes [20]byte + + binary.LittleEndian.PutUint16(sizes[:], zip64ExtraID) + binary.LittleEndian.PutUint16(sizes[2:], 16) + binary.LittleEndian.PutUint64(sizes[4:], header.UncompressedSize64) + binary.LittleEndian.PutUint64(sizes[12:], header.CompressedSize64) + + return append(result, sizes[:]...) +} + +// ZIP64 values appear only for saturated fields, in this order: +// uncompressed size, compressed size, local-header offset, disk number. +func zipLocalHeaderOffset(central, extra []byte) (uint64, error) { + offset := binary.LittleEndian.Uint32(central[42:]) + if offset != math.MaxUint32 { + return uint64(offset), nil + } + + skip := 0 + if binary.LittleEndian.Uint32(central[24:]) == math.MaxUint32 { + skip += 8 + } + + if binary.LittleEndian.Uint32(central[20:]) == math.MaxUint32 { + skip += 8 + } + + for len(extra) >= 4 { + tag := binary.LittleEndian.Uint16(extra) + + length := int(binary.LittleEndian.Uint16(extra[2:])) + if length > len(extra)-4 { + return 0, zip.ErrFormat + } + + if tag == zip64ExtraID { + if length < skip+8 { + return 0, zip.ErrFormat + } + + return binary.LittleEndian.Uint64(extra[4+skip:]), nil + } + + extra = extra[4+length:] + } + + return 0, zip.ErrFormat +} diff --git a/pkg/appstore/appstore_zip_headers_test.go b/pkg/appstore/appstore_zip_headers_test.go new file mode 100644 index 0000000..a42d1d0 --- /dev/null +++ b/pkg/appstore/appstore_zip_headers_test.go @@ -0,0 +1,209 @@ +package appstore + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "hash/crc32" + "io" + "math" + "os" + "path/filepath" + + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("AppStore (ZIP local metadata)", func() { + // Local Unix timestamp field captured from the affected United Airlines IPA. + unixTimestamp := []byte{0x55, 0x58, 12, 0, 0xb1, 0x24, 0x51, 0x6a, 0xb1, 0x24, 0x51, 0x6a, 0xcb, 0, 0xc8, 0} + localTimestamp := []byte{0x55, 0x54, 9, 0, 3, 0xb2, 0x27, 0x51, 0x6a, 0xb2, 0x27, 0x51, 0x6a} + centralTimestamp := []byte{0x55, 0x54, 5, 0, 1, 0xb2, 0x27, 0x51, 0x6a} + DescribeTable("preserves independent extra fields through metadata and SINF patches", + func(local, central []byte) { + source := timestampZIP(local, central) + dir, err := os.MkdirTemp("", "ipatool-zip-timestamps-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(os.RemoveAll, dir) + original, patched := filepath.Join(dir, "original.ipa"), filepath.Join(dir, "patched.ipa") + Expect(os.WriteFile(original, source, 0600)).To(Succeed()) + store := &appstore{os: operatingsystem.New()} + Expect(store.applyPatches(downloadItemResult{Metadata: map[string]interface{}{}}, Account{}, original, patched, nil)).To(Succeed()) + checkTimestampZIP(patched, local, central) + Expect(store.ReplicateSinf(ReplicateSinfInput{PackagePath: patched, Sinfs: []Sinf{{Data: []byte("sinf")}}})).To(Succeed()) + checkTimestampZIP(patched, local, central) + }, + Entry("United Airlines local-only Unix timestamp", unixTimestamp, []byte{}), + Entry("identical local and central timestamps", centralTimestamp, centralTimestamp), + Entry("local-only extended timestamp", localTimestamp, []byte{}), + Entry("different local and central timestamps", localTimestamp, centralTimestamp), + Entry("central-only extended timestamp", []byte{}, centralTimestamp), + Entry("no extended timestamps", []byte{}, []byte{}), + Entry("unknown local field", []byte{0xfe, 0xca, 3, 0, 1, 2, 3}, []byte{}), + ) + + It("reads local extras in central-directory order, including prefixed archives", func() { + data := timestampZIP(localTimestamp, nil) + end := len(data) - 22 + start := int(binary.LittleEndian.Uint32(data[end+16:])) + firstLen := 46 + int(binary.LittleEndian.Uint16(data[start+28:])) + int(binary.LittleEndian.Uint16(data[start+30:])) + reordered := append([]byte{}, data[:start]...) + reordered = append(reordered, data[start+firstLen:end]...) + reordered = append(reordered, data[start:start+firstLen]...) + reordered = append(reordered, data[end:]...) + reordered = append([]byte("archive prefix"), reordered...) + reader, err := zip.NewReader(bytes.NewReader(reordered), int64(len(reordered))) + Expect(err).ToNot(HaveOccurred()) + headers, err := newZIPLocalHeaders(bytes.NewReader(reordered), int64(len(reordered))) + Expect(err).ToNot(HaveOccurred()) + for _, file := range reader.File { + extra, err := headers.extra(file) + Expect(err).ToNot(HaveOccurred()) + if file.Name == "Payload/Test.app/_CodeSignature/CodeResources" { + Expect(extra).To(Equal(localTimestamp)) + } + } + }) + + It("reads ZIP64 entry offsets and a ZIP64 end record", func() { + out := zip64TimestampZIP(localTimestamp) + reader, err := zip.NewReader(bytes.NewReader(out), int64(len(out))) + Expect(err).ToNot(HaveOccurred()) + headers, err := newZIPLocalHeaders(bytes.NewReader(out), int64(len(out))) + Expect(err).ToNot(HaveOccurred()) + extra, err := headers.extra(reader.File[0]) + Expect(err).ToNot(HaveOccurred()) + Expect(extra).To(Equal(localTimestamp)) + + By("rewriting the ZIP64 archive without retaining stale central offsets") + dir, err := os.MkdirTemp("", "ipatool-zip64-*") + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(os.RemoveAll, dir) + path := filepath.Join(dir, "source.zip") + Expect(os.WriteFile(path, out, 0600)).To(Succeed()) + src, err := zip.OpenReader(path) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(src.Close) + var output bytes.Buffer + dst := zip.NewWriter(&output) + Expect((&appstore{}).replicateZip(src, dst, path)).To(Succeed()) + Expect(dst.Close()).To(Succeed()) + resultPath := filepath.Join(dir, "result.zip") + Expect(os.WriteFile(resultPath, output.Bytes(), 0600)).To(Succeed()) + checkTimestampZIP(resultPath, localTimestamp, []byte{}) + }) + + It("replaces local ZIP64 placeholder sizes when removing a stored entry's descriptor", func() { + extra := append([]byte{}, unixTimestamp...) + placeholder := make([]byte, 20) + binary.LittleEndian.PutUint16(placeholder, 1) + binary.LittleEndian.PutUint16(placeholder[2:], 16) + extra = append(extra, placeholder...) + size := uint64(math.MaxUint32) + 10 + result := zipStoredExtra(&zip.FileHeader{Method: zip.Store, CompressedSize64: size, UncompressedSize64: size}, extra) + Expect(result[:len(unixTimestamp)]).To(Equal(unixTimestamp)) + Expect(result).To(HaveLen(len(unixTimestamp) + 20)) + Expect(binary.LittleEndian.Uint64(result[len(unixTimestamp)+4:])).To(Equal(size)) + Expect(binary.LittleEndian.Uint64(result[len(unixTimestamp)+12:])).To(Equal(size)) + }) + + It("rejects a truncated local extra field", func() { + data := timestampZIP(localTimestamp, nil) + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).ToNot(HaveOccurred()) + binary.LittleEndian.PutUint16(data[28:], 0xffff) + headers, err := newZIPLocalHeaders(bytes.NewReader(data), int64(len(data))) + Expect(err).ToNot(HaveOccurred()) + _, err = headers.extra(reader.File[0]) + Expect(err).To(HaveOccurred()) + }) +}) + +// Build distinct local/central metadata without using the production helper. +func timestampZIP(local, central []byte) []byte { + var output bytes.Buffer + writer := zip.NewWriter(&output) + payload := []byte("signature") + header := &zip.FileHeader{ + Name: "Payload/Test.app/_CodeSignature/CodeResources", + Method: zip.Store, + ModifiedDate: 0x5cea, + ModifiedTime: 0x874d, + Extra: local, + CRC32: crc32.ChecksumIEEE(payload), + CompressedSize64: uint64(len(payload)), + UncompressedSize64: uint64(len(payload)), + } + entry, err := writer.CreateRaw(header) + Expect(err).ToNot(HaveOccurred()) + _, err = entry.Write(payload) + Expect(err).ToNot(HaveOccurred()) + + header.Extra = central + info, err := writer.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + _, err = io.WriteString(info, `CFBundleExecutableTest`) + Expect(err).ToNot(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + + return output.Bytes() +} + +func checkTimestampZIP(path string, local, central []byte) { + data, err := os.ReadFile(path) + Expect(err).ToNot(HaveOccurred()) + // This fixture's CodeResources is the first entry. Inspect its local bytes + // independently of the production reader and Go's central-only FileHeader. + nameLen := int(binary.LittleEndian.Uint16(data[26:])) + extraLen := int(binary.LittleEndian.Uint16(data[28:])) + Expect(data[30+nameLen : 30+nameLen+extraLen]).To(Equal(local)) + Expect(binary.LittleEndian.Uint16(data[10:])).To(Equal(uint16(0x874d))) + Expect(binary.LittleEndian.Uint16(data[12:])).To(Equal(uint16(0x5cea))) + reader, err := zip.NewReader(bytes.NewReader(data), int64(len(data))) + Expect(err).ToNot(HaveOccurred()) + Expect(reader.File[0].Extra).To(BeEquivalentTo(central)) + entry, err := reader.File[0].Open() + Expect(err).ToNot(HaveOccurred()) + payload, err := io.ReadAll(entry) + Expect(err).ToNot(HaveOccurred()) + Expect(entry.Close()).To(Succeed()) + Expect(string(payload)).To(Equal("signature")) +} + +// Force ZIP64 offsets and end records without allocating a multi-gigabyte fixture. +func zip64TimestampZIP(local []byte) []byte { + data := timestampZIP(local, nil) + end := len(data) - 22 + start := int(binary.LittleEndian.Uint32(data[end+16:])) + nameLen := int(binary.LittleEndian.Uint16(data[start+28:])) + offsetExtra := make([]byte, 12) + binary.LittleEndian.PutUint16(offsetExtra, 1) + binary.LittleEndian.PutUint16(offsetExtra[2:], 8) + binary.LittleEndian.PutUint32(data[start+42:], 0xffffffff) + binary.LittleEndian.PutUint16(data[start+30:], 12) + out := append([]byte{}, data[:start+46+nameLen]...) + out = append(out, offsetExtra...) + out = append(out, data[start+46+nameLen:end]...) + zip64Offset := len(out) + end64 := make([]byte, 56) + binary.LittleEndian.PutUint32(end64, 0x06064b50) + binary.LittleEndian.PutUint64(end64[4:], 44) + binary.LittleEndian.PutUint64(end64[24:], 2) + binary.LittleEndian.PutUint64(end64[32:], 2) + binary.LittleEndian.PutUint64(end64[40:], uint64(zip64Offset-start)) + binary.LittleEndian.PutUint64(end64[48:], uint64(start)) + out = append(out, end64...) + locator := make([]byte, 20) + binary.LittleEndian.PutUint32(locator, 0x07064b50) + binary.LittleEndian.PutUint64(locator[8:], uint64(zip64Offset)) + binary.LittleEndian.PutUint32(locator[16:], 1) + out = append(out, locator...) + eocd := append([]byte{}, data[end:]...) + binary.LittleEndian.PutUint16(eocd[10:], 0xffff) + binary.LittleEndian.PutUint32(eocd[12:], 0xffffffff) + binary.LittleEndian.PutUint32(eocd[16:], 0xffffffff) + out = append(out, eocd...) + + return out +} diff --git a/pkg/appstore/authentication_retry_test.go b/pkg/appstore/authentication_retry_test.go new file mode 100644 index 0000000..f401fe9 --- /dev/null +++ b/pkg/appstore/authentication_retry_test.go @@ -0,0 +1,175 @@ +package appstore + +import ( + "encoding/base64" + "errors" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + "time" + + "github.com/majd/ipatool/v2/pkg/keychain" + "howett.net/plist" + + apphttp "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +const testAuthEndpoint = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + +var _ = Describe("Authentication recovery", func() { + DescribeTable("honors Retry-After without exceeding the wait budget", + func(status int, header string, calls int, expectedWaits []time.Duration) { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + responseErr := &apphttp.UnexpectedResponseError{StatusCode: status, RetryAfter: header} + client.EXPECT().Send(gomock.Any()).Return(apphttp.Result[loginResult]{}, responseErr).Times(calls) + var waits []time.Duration + sut := &appstore{loginClient: client, authRetrySleep: func(delay time.Duration) { waits = append(waits, delay) }} + _, err := sut.sendAuthenticationRequest(apphttp.Request{}) + Expect(errors.Is(err, responseErr)).To(BeTrue()) + if calls == 1 { + Expect(waits).To(BeEmpty()) + } else { + Expect(waits).To(Equal(expectedWaits)) + } + }, + Entry("503 with a requested delay", 503, "5", 3, []time.Duration{5 * time.Second, 5 * time.Second}), + Entry("429 with a requested delay", 429, "3", 3, []time.Duration{3 * time.Second, 3 * time.Second}), + Entry("429 without a deadline", 429, "", 3, []time.Duration{10 * time.Second, 20 * time.Second}), + Entry("429 with an invalid deadline", 429, "invalid", 3, []time.Duration{10 * time.Second, 20 * time.Second}), + Entry("503 without a deadline", 503, "", 3, []time.Duration{10 * time.Second, 20 * time.Second}), + Entry("204 without a deadline", 204, "", 3, []time.Duration{10 * time.Second, 20 * time.Second}), + Entry("404 without a deadline", 404, "", 3, []time.Duration{10 * time.Second, 20 * time.Second}), + Entry("Retry-After zero avoids a tight retry loop", 429, "0", 3, []time.Duration{time.Second, time.Second}), + Entry("503 with a long deadline", 503, "3600", 1, []time.Duration(nil)), + Entry("403 is not retried", 403, "1", 1, []time.Duration(nil)), + Entry("malformed redirect is not retried", 302, "", 1, []time.Duration(nil)), + ) + + It("parses dates and seconds without overflow or negative waits", func() { + now := time.Date(2026, 9, 13, 12, 0, 0, 0, time.UTC) + for value, want := range map[string]time.Duration{ + now.Add(5 * time.Second).Format(http.TimeFormat): 5 * time.Second, + now.Add(-time.Second).Format(http.TimeFormat): 0, + "0": 0, " 5 ": 5 * time.Second, + "18446744073709551615": maxAuthenticationRetryDelay + time.Second, + } { + delay, ok := authenticationRetryAfter(value, now) + Expect(ok).To(BeTrue()) + Expect(delay).To(Equal(want)) + } + for _, value := range []string{"", "-1", "invalid"} { + _, ok := authenticationRetryAfter(value, now) + Expect(ok).To(BeFalse()) + } + }) + + It("does not retry populated Apple credential errors or 2FA challenges", func() { + ctrl := gomock.NewController(GinkgoT()) + client := apphttp.NewMockClient[loginResult](ctrl) + sut := &appstore{loginClient: client, authRetrySleep: func(time.Duration) { Fail("must not sleep") }} + for _, data := range []loginResult{ + {FailureType: FailureTypeInvalidCredentials, CustomerMessage: "invalid credentials"}, + {CustomerMessage: CustomerMessageBadLogin}, + } { + client.EXPECT().Send(gomock.Any()).Return(apphttp.Result[loginResult]{StatusCode: 200, Data: data}, nil).Times(1) + result, err := sut.sendAuthenticationRequest(apphttp.Request{}) + Expect(err).NotTo(HaveOccurred()) + _, _, err = sut.parseLoginResponse(&result, "") + Expect(err).To(HaveOccurred()) + Expect(err.Error()).NotTo(ContainSubstring("another network")) + } + }) +}) + +// The adapter only redirects network traffic to the local server; login still +// validates Apple's advertised pod URL before this adapter receives a request. +type loginSessionJar struct{ *cookiejar.Jar } + +func (loginSessionJar) Save() error { return nil } + +var _ = Describe("Login session continuity", func() { + It("retains cookies and the signed payload through retries, a pod redirect and 2FA", func() { + ctrl := gomock.NewController(GinkgoT()) + jar, err := cookiejar.New(nil) + Expect(err).NotTo(HaveOccurred()) + client := apphttp.NewClient[loginResult](apphttp.Args{ + CookieJar: loginSessionJar{jar}, + Authentication: true, + ActionSigner: func(data []byte) ([]byte, error) { return data, nil }, + }) + adapter := apphttp.NewMockClient[loginResult](ctrl) + keychain := keychain.NewMockKeychain(ctrl) + keychain.EXPECT().Set("account", gomock.Any()).Return(nil).Times(1) + sut := &appstore{loginClient: adapter, keychain: keychain, authRetrySleep: func(time.Duration) {}} + connections := make(chan string, 4) + calls := 0 + podURL := "https://p7-buy.itunes.apple.com" + PrivateAppStoreAPIPathAuth + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls++ + connections <- r.RemoteAddr + Expect(r.Method).To(Equal(http.MethodPost)) + body, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + signature, err := base64.StdEncoding.DecodeString(r.Header.Get(apphttp.HeaderAppleActionSignature)) + Expect(err).NotTo(HaveOccurred()) + Expect(signature).To(Equal(body)) + var payload map[string]string + _, err = plist.Unmarshal(body, &payload) + Expect(err).NotTo(HaveOccurred()) + Expect(payload["attempt"]).To(Equal("1")) + if calls > 1 { + cookie, err := r.Cookie("session") + Expect(err).NotTo(HaveOccurred()) + Expect(cookie.Value).To(Equal("retained")) + } + switch calls { + case 1: + http.SetCookie(w, &http.Cookie{Name: "session", Value: "retained", Path: "/"}) + w.WriteHeader(http.StatusNoContent) + case 2: + w.Header().Set("Location", podURL) + w.WriteHeader(http.StatusFound) + case 3: + Expect(payload["password"]).To(Equal("password")) + data, err := plist.Marshal(map[string]string{"customerMessage": CustomerMessageBadLogin}, plist.XMLFormat) + Expect(err).NotTo(HaveOccurred()) + _, err = w.Write(data) + Expect(err).NotTo(HaveOccurred()) + case 4: + Expect(payload["password"]).To(Equal("password123456")) + w.Header().Set(HTTPHeaderStoreFront, "143441-1,29") + _, err = w.Write([]byte("passwordTokentokendsPersonId123")) + Expect(err).NotTo(HaveOccurred()) + default: + Fail("unexpected request") + } + })) + DeferCleanup(srv.Close) + adapted := 0 + adapter.EXPECT().Send(gomock.Any()).DoAndReturn(func(request apphttp.Request) (apphttp.Result[loginResult], error) { + adapted++ + if adapted == 3 { + Expect(request.URL).To(Equal(podURL)) + } + request.URL = srv.URL + PrivateAppStoreAPIPathAuth + + return client.Send(request) + }).Times(4) + _, err = sut.login("email", "password", "", "guid", testAuthEndpoint) + Expect(errors.Is(err, ErrAuthCodeRequired)).To(BeTrue()) + account, err := sut.login("email", "password", "123456", "guid", testAuthEndpoint) + Expect(err).NotTo(HaveOccurred()) + Expect(account.PasswordToken).To(Equal("token")) + seen := map[string]bool{} + for range 4 { + seen[<-connections] = true + } + Expect(seen).To(HaveLen(4)) + }) +}) diff --git a/pkg/appstore/constants.go b/pkg/appstore/constants.go index 354f916..52216c3 100644 --- a/pkg/appstore/constants.go +++ b/pkg/appstore/constants.go @@ -22,6 +22,7 @@ const ( PrivateInitPath = "/bag.xml" PrivateAppStoreAPIDomain = "buy." + iTunesAPIDomain + PrivateAppStoreAPIPathAuth = "/WebObjects/MZFinance.woa/wa/authenticate" PrivateAppStoreAPIPathPurchase = "/WebObjects/MZFinance.woa/wa/buyProduct" PrivateAppStoreAPIPathDownload = "/WebObjects/MZFinance.woa/wa/volumeStoreDownloadProduct" diff --git a/pkg/appstore/platform.go b/pkg/appstore/platform.go index e76ee42..f446293 100644 --- a/pkg/appstore/platform.go +++ b/pkg/appstore/platform.go @@ -1,25 +1,36 @@ package appstore -import "fmt" +import ( + "fmt" + "strings" +) type Platform string const ( - PlatformIPhone Platform = "iphone" - PlatformIPad Platform = "ipad" - PlatformAppleTV Platform = "appletv" + // PlatformUnknown is an output value, not a selectable store platform. + PlatformUnknown Platform = "unknown" + PlatformIPhone Platform = "iphone" + PlatformIPad Platform = "ipad" + PlatformAppleTV Platform = "appletv" + PlatformVisionOS Platform = "visionos" + PlatformMacOS Platform = "macos" ) func ParsePlatform(value string) (Platform, error) { - switch value { + switch strings.ToLower(value) { case "": return "", nil - case "iphone", "iPhone", "ios", "iOS": + case "iphone", "ios": return PlatformIPhone, nil - case "ipad", "iPad": + case "ipad", "ipados": return PlatformIPad, nil - case "appletv", "AppleTV", "apple-tv", "tvos", "tvOS": + case "appletv", "apple-tv", "tvos": return PlatformAppleTV, nil + case "vision", "visionos", "visionpro", "xros", "realitydevice": + return PlatformVisionOS, nil + case "mac", "macos", "osx": + return PlatformMacOS, nil default: return "", fmt.Errorf("invalid platform %q", value) } @@ -35,6 +46,10 @@ func (p Platform) lookupEntity() (string, error) { return "iPadSoftware", nil case PlatformAppleTV: return "tvSoftware", nil + case PlatformVisionOS: + return "xrosSoftware", nil + case PlatformMacOS: + return "macSoftware", nil default: return "", fmt.Errorf("invalid platform %q", p) } @@ -50,6 +65,10 @@ func (p Platform) searchEntity() (string, error) { return "iPadSoftware", nil case PlatformAppleTV: return "software,tvSoftware", nil + case PlatformVisionOS: + return "xrosSoftware", nil + case PlatformMacOS: + return "macSoftware", nil default: return "", fmt.Errorf("invalid platform %q", p) } @@ -61,6 +80,8 @@ func (p Platform) metadataPlatform() (string, error) { return "enterprisestore", nil case PlatformAppleTV: return "atv9", nil + case PlatformVisionOS: + return "realityDevice", nil default: return "", fmt.Errorf("invalid platform %q", p) } diff --git a/pkg/http/authentication_test.go b/pkg/http/authentication_test.go new file mode 100644 index 0000000..567983d --- /dev/null +++ b/pkg/http/authentication_test.go @@ -0,0 +1,164 @@ +package http + +import ( + "encoding/base64" + "io" + "net/http" + "net/http/cookiejar" + "net/http/httptest" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +type sessionCookieJar struct{ *cookiejar.Jar } + +func (sessionCookieJar) Save() error { return nil } + +var _ = Describe("Authentication transport", func() { + DescribeTable("preserves cookies and signed POSTs across fresh connections", + func(http2 bool) { + jar, err := cookiejar.New(nil) + Expect(err).NotTo(HaveOccurred()) + signer := &recordingActionSigner{signature: []byte("signature")} + addresses := make(chan string, 4) + calls := 0 + srv := httptest.NewUnstartedServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + defer GinkgoRecover() + calls++ + addresses <- r.RemoteAddr + Expect(r.ProtoMajor).To(Equal(map[bool]int{false: 1, true: 2}[http2])) + Expect(r.Method).To(Equal(http.MethodPost)) + data, err := io.ReadAll(r.Body) + Expect(err).NotTo(HaveOccurred()) + Expect(data).To(Equal(signer.data)) + Expect(r.Header.Get(HeaderAppleActionSignature)).To(Equal(base64.StdEncoding.EncodeToString(signer.signature))) + if calls > 1 { + cookie, err := r.Cookie("session") + Expect(err).NotTo(HaveOccurred()) + Expect(cookie.Value).To(Equal("retained")) + } + switch calls { + case 1: + http.SetCookie(w, &http.Cookie{Name: "session", Value: "retained", Path: "/"}) + w.WriteHeader(http.StatusNoContent) + case 2: + w.Header().Set("Location", "https://p7-buy.itunes.apple.com"+appStoreAuthPath) + w.WriteHeader(http.StatusFound) + default: + _, err = w.Write([]byte("foook")) + Expect(err).NotTo(HaveOccurred()) + } + })) + srv.EnableHTTP2 = http2 + srv.StartTLS() + DeferCleanup(srv.Close) + + original := http.DefaultTransport.(*http.Transport).DisableKeepAlives + sut := NewClient[struct { + Foo string `plist:"foo"` + }](Args{CookieJar: sessionCookieJar{jar}, Authentication: true, ActionSigner: func(data []byte) ([]byte, error) { + signer.data = data + signer.calls++ + + return signer.signature, nil + }}).(*client[struct { + Foo string `plist:"foo"` + }]) + transport := sut.internalClient.Transport.(*AddHeaderTransport).T.(*http.Transport) + // Trust only the test server's certificate; retain the production transport settings. + transport.TLSClientConfig = srv.Client().Transport.(*http.Transport).TLSClientConfig.Clone() + DeferCleanup(transport.CloseIdleConnections) + request := Request{URL: srv.URL + appStoreAuthPath, Method: MethodPOST, ResponseFormat: ResponseFormatXML, SignAction: true, Payload: &XMLPayload{Content: map[string]interface{}{"password": "password", "attempt": "1"}}} + _, err = sut.Send(request) + Expect(err).To(HaveOccurred()) + redirect, err := sut.Send(request) + Expect(err).NotTo(HaveOccurred()) + Expect(redirect.StatusCode).To(Equal(http.StatusFound)) + Expect(redirect.Headers).To(HaveKey("Location")) + // The appstore layer validates the pod URL and explicitly repeats the POST. + // Route that POST to this server to inspect it without contacting Apple. + _, err = sut.Send(request) + Expect(err).NotTo(HaveOccurred()) + request.Payload = &XMLPayload{Content: map[string]interface{}{"password": "password123456", "attempt": "1"}} + _, err = sut.Send(request) + Expect(err).NotTo(HaveOccurred()) + Expect(signer.calls).To(Equal(4)) + seen := map[string]bool{} + for range 4 { + seen[<-addresses] = true + } + Expect(seen).To(HaveLen(4)) + Expect(http.DefaultTransport.(*http.Transport).DisableKeepAlives).To(Equal(original)) + }, Entry("HTTP/1.1", false), Entry("HTTP/2", true), + ) + + It("preserves a populated Apple error even with HTTP 429", func() { + recorder := httptest.NewRecorder() + recorder.Header().Set("Retry-After", "5") + recorder.WriteHeader(http.StatusTooManyRequests) + _, err := recorder.WriteString("failureTypebadCredentials") + Expect(err).NotTo(HaveOccurred()) + sut := &client[map[string]string]{authentication: true} + result, err := sut.handleXMLResponse(recorder.Result()) + Expect(err).NotTo(HaveOccurred()) + Expect(result.Data).To(HaveKeyWithValue("failureType", "badCredentials")) + Expect(result.StatusCode).To(Equal(http.StatusTooManyRequests)) + }) + + It("retains pooling for ordinary clients", func() { + addresses := make(chan string, 2) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + addresses <- r.RemoteAddr + w.WriteHeader(http.StatusNoContent) + })) + DeferCleanup(srv.Close) + sut := NewClient[struct{}](Args{}) + for range 2 { + request, err := http.NewRequest(http.MethodGet, srv.URL, nil) + Expect(err).NotTo(HaveOccurred()) + response, err := sut.Do(request) + Expect(err).NotTo(HaveOccurred()) + Expect(response.Body.Close()).To(Succeed()) + } + Expect(<-addresses).To(Equal(<-addresses)) + }) + + DescribeTable("reports malformed responses without retaining credentials", + func(status int, body, reason string) { + recorder := httptest.NewRecorder() + recorder.Header().Set("Content-Type", "text/html") + recorder.Header().Set("X-Apple-Jingle-Correlation-Key", "correlation-123") + recorder.Header().Set("Set-Cookie", "secret-cookie") + recorder.Header().Set("Retry-After", "5") + recorder.WriteHeader(status) + _, err := recorder.WriteString(body) + Expect(err).NotTo(HaveOccurred()) + sut := &client[struct{}]{authentication: true} + _, err = sut.handleXMLResponse(recorder.Result()) + Expect(err).To(HaveOccurred()) + responseErr, ok := err.(*UnexpectedResponseError) + Expect(ok).To(BeTrue()) + Expect(responseErr.StatusCode).To(Equal(status)) + Expect(responseErr.BodyLength).To(Equal(len(body))) + Expect(responseErr.ContentType).To(Equal("text/html")) + Expect(responseErr.CorrelationID).To(Equal("correlation-123")) + Expect(responseErr.RetryAfter).To(Equal("5")) + Expect(responseErr.Reason).To(Equal(reason)) + Expect(responseErr.Snippet).To(BeEmpty()) + Expect(err.Error()).NotTo(ContainSubstring("secret")) + }, + Entry("empty forbidden response", 403, "", "empty or non-plist authentication response"), + Entry("HTML failure", 500, "secret-password", "empty or non-plist authentication response"), + Entry("redirect without Location", 302, "secret-token", "authentication redirect is missing Location"), + Entry("rate limit", 429, "secret-token", "rate limited by Apple"), + Entry("malformed plist", 200, "secret-token", "malformed authentication plist"), + ) +}) + +// recordingActionSigner captures what the client signed. +type recordingActionSigner struct { + data []byte + signature []byte + calls int +} diff --git a/pkg/http/client.go b/pkg/http/client.go index 72a2e22..8a2b5bc 100644 --- a/pkg/http/client.go +++ b/pkg/http/client.go @@ -18,7 +18,8 @@ import ( ) const ( - appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + appStoreAuthURL = "https://buy.itunes.apple.com/WebObjects/MZFinance.woa/wa/authenticate" + appStoreAuthPath = "/WebObjects/MZFinance.woa/wa/authenticate" ) // Connection setup budgets. @@ -56,23 +57,36 @@ type client[R interface{}] struct { internalClient http.Client cookieJar CookieJar actionSigner ActionSigner + authentication bool } type ActionSigner func(data []byte) ([]byte, error) type Args struct { - CookieJar CookieJar + CookieJar CookieJar + // ActionSigner signs Apple actions (SAP) for endpoints that require it. ActionSigner ActionSigner + // Authentication isolates login connections and reports only allowlisted response diagnostics. + Authentication bool } // UnexpectedResponseError preserves the HTTP status when Apple returns an // HTML or empty response where an XML plist was expected. type UnexpectedResponseError struct { - StatusCode int - Snippet string + StatusCode int + Snippet string + Reason string + BodyLength int + ContentType string + CorrelationID string + RetryAfter string } func (e *UnexpectedResponseError) Error() string { + if e.Reason != "" { + return fmt.Sprintf("unexpected response from Apple (HTTP %d): %s (body length=%d, content type=%q, correlation ID=%q)", e.StatusCode, e.Reason, e.BodyLength, e.ContentType, e.CorrelationID) + } + if e.Snippet == "" { return fmt.Sprintf("unexpected response from Apple (HTTP %d): empty or non-plist body", e.StatusCode) } @@ -106,6 +120,14 @@ func NewClient[R interface{}](args Args) Client[R] { transport = http.DefaultTransport.(*http.Transport).Clone() } + if args.Authentication { + // Keep the cookie jar, but do not share pooled connections with other + // Store operations or reuse a connection for the next login attempt. + isolated := transport.Clone() + isolated.DisableKeepAlives = true + transport = isolated + } + // Bound the setup phases rather than the whole exchange; streaming bodies // stay unlimited so large packages are unaffected. transport.DialContext = (&net.Dialer{ @@ -118,6 +140,7 @@ func NewClient[R interface{}](args Args) Client[R] { transport.IdleConnTimeout = idleConnTimeout return &client[R]{ + authentication: args.Authentication, internalClient: http.Client{ Timeout: 0, Jar: args.CookieJar, @@ -259,7 +282,11 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { return Result[R]{}, fmt.Errorf("failed to read response body: %w", err) } - if res.StatusCode == http.StatusTooManyRequests { + if c.authentication && res.StatusCode == http.StatusFound && strings.TrimSpace(res.Header.Get("Location")) == "" { + return Result[R]{}, authenticationResponseError(res, body, "authentication redirect is missing Location") + } + + if !c.authentication && res.StatusCode == http.StatusTooManyRequests { return Result[R]{}, fmt.Errorf("rate limited by Apple (HTTP %d): %s", res.StatusCode, strings.TrimSpace(string(body))) } @@ -278,6 +305,14 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { normalizedBody := normalizeXMLPlistBody(body) if !looksLikePropertyList(normalizedBody) { + if c.authentication { + if res.StatusCode == http.StatusTooManyRequests { + return Result[R]{}, authenticationResponseError(res, body, "rate limited by Apple") + } + + return Result[R]{}, authenticationResponseError(res, body, "empty or non-plist authentication response") + } + snippet := bodySnippet(body) return Result[R]{}, &UnexpectedResponseError{ @@ -288,6 +323,10 @@ func (c *client[R]) handleXMLResponse(res *http.Response) (Result[R], error) { _, err = plist.Unmarshal(normalizedBody, &data) if err != nil { + if c.authentication { + return Result[R]{}, authenticationResponseError(res, body, "malformed authentication plist") + } + return Result[R]{}, fmt.Errorf("failed to unmarshal xml: %w", err) } @@ -408,3 +447,16 @@ func extractDocumentInnerBody(body []byte) []byte { return documentBody } + +// Do not retain bodies or arbitrary response headers: authentication responses +// can contain credentials, tokens and cookies, including on failure. +func authenticationResponseError(res *http.Response, body []byte, reason string) *UnexpectedResponseError { + return &UnexpectedResponseError{ + StatusCode: res.StatusCode, + Reason: reason, + BodyLength: len(body), + ContentType: bodySnippet([]byte(res.Header.Get("Content-Type"))), + CorrelationID: bodySnippet([]byte(res.Header.Get("X-Apple-Jingle-Correlation-Key"))), + RetryAfter: res.Header.Get("Retry-After"), + } +} From 90893e426d631768e47c4cb908dde5f4bc8ea9e4 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 17:00:54 +0200 Subject: [PATCH 02/18] feat: support macos in list-versions Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cmd/list_versions.go | 22 +++++--- cmd/list_versions_test.go | 60 +++++++++++++++++++++ pkg/appstore/appstore_list_versions.go | 40 +++++++++++--- pkg/appstore/appstore_list_versions_test.go | 37 +++++++++++++ 4 files changed, 146 insertions(+), 13 deletions(-) create mode 100644 cmd/list_versions_test.go diff --git a/cmd/list_versions.go b/cmd/list_versions.go index a583703..987e0b2 100644 --- a/cmd/list_versions.go +++ b/cmd/list_versions.go @@ -13,18 +13,24 @@ import ( // nolint:wrapcheck func ListVersionsCmd() *cobra.Command { var ( - appID int64 - bundleID string + appID int64 + bundleID string + platformValue string ) cmd := &cobra.Command{ Use: "list-versions", - Short: "List the available versions of an iOS app", + Short: "List the available versions of an App Store app", RunE: func(cmd *cobra.Command, args []string) error { if appID == 0 && bundleID == "" { return errors.New("either the app ID or the bundle identifier must be specified") } + platform, err := appstore.ParsePlatform(platformValue) + if err != nil { + return err + } + var lastErr error var acc appstore.Account @@ -56,7 +62,7 @@ func ListVersionsCmd() *cobra.Command { app := appstore.App{ID: appID} if bundleID != "" { - lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) + lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID, Platform: platform}) if err != nil { return err } @@ -64,7 +70,7 @@ func ListVersionsCmd() *cobra.Command { app = lookupResult.App } - out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app}) + out, err := dependencies.AppStore.ListVersions(appstore.ListVersionsInput{Account: acc, App: app, Platform: platform}) if err != nil { return err } @@ -90,8 +96,10 @@ func ListVersionsCmd() *cobra.Command { }, } - cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") - cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") + cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target app (required)") + cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)") + + cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to list versions for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos") return cmd } diff --git a/cmd/list_versions_test.go b/cmd/list_versions_test.go new file mode 100644 index 0000000..719cc08 --- /dev/null +++ b/cmd/list_versions_test.go @@ -0,0 +1,60 @@ +package cmd + +import ( + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("List Versions command", func() { + DescribeTable("passes the selected platform to lookup and version listing", func(args []string, expected appstore.Platform, lookup bool) { + store := &fakeListVersionsAppStore{} + previous := dependencies + DeferCleanup(func() { dependencies = previous }) + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + cmd := ListVersionsCmd() + cmd.SetArgs(args) + Expect(cmd.Execute()).To(Succeed()) + Expect(store.input.Platform).To(Equal(expected)) + Expect(store.input.App.ID).To(Equal(int64(42))) + Expect(store.lookupCalled).To(Equal(lookup)) + if lookup { + Expect(store.lookupInput.Platform).To(Equal(expected)) + } + }, + Entry("Mac bundle ID", []string{"-b", "com.example.mac", "--platform", "macos"}, appstore.PlatformMacOS, true), + Entry("Mac app ID alias", []string{"-i", "42", "--platform", "mac"}, appstore.PlatformMacOS, false), + Entry("default platform", []string{"-i", "42"}, appstore.Platform(""), false), + Entry("iOS alias", []string{"-i", "42", "--platform", "ios"}, appstore.PlatformIPhone, false), + ) + + It("rejects unknown as a platform before accessing the account", func() { + cmd := ListVersionsCmd() + cmd.SetArgs([]string{"-i", "42", "--platform", "unknown"}) + Expect(cmd.Execute()).To(MatchError(`invalid platform "unknown"`)) + }) +}) + +type fakeListVersionsAppStore struct { + appstore.AppStore + input appstore.ListVersionsInput + lookupInput appstore.LookupInput + lookupCalled bool +} + +func (*fakeListVersionsAppStore) AccountInfo() (appstore.AccountInfoOutput, error) { + return appstore.AccountInfoOutput{}, nil +} +func (s *fakeListVersionsAppStore) Lookup(input appstore.LookupInput) (appstore.LookupOutput, error) { + s.lookupCalled = true + s.lookupInput = input + + return appstore.LookupOutput{App: appstore.App{ID: 42, BundleID: input.BundleID}}, nil +} +func (s *fakeListVersionsAppStore) ListVersions(input appstore.ListVersionsInput) (appstore.ListVersionsOutput, error) { + s.input = input + + return appstore.ListVersionsOutput{ExternalVersionIdentifiers: []string{"123"}}, nil +} diff --git a/pkg/appstore/appstore_list_versions.go b/pkg/appstore/appstore_list_versions.go index 9cc7d1f..8382aa4 100644 --- a/pkg/appstore/appstore_list_versions.go +++ b/pkg/appstore/appstore_list_versions.go @@ -9,8 +9,9 @@ import ( ) type ListVersionsInput struct { - Account Account - App App + Account Account + App App + Platform Platform } type ListVersionsOutput struct { @@ -19,6 +20,17 @@ type ListVersionsOutput struct { } func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, error) { + platform := input.Platform + if platform == "" { + platform = PlatformIPhone + } + + switch platform { + case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS: + default: + return ListVersionsOutput{}, fmt.Errorf("invalid platform %q", platform) + } + macAddr, err := t.machine.MacAddress() if err != nil { return ListVersionsOutput{}, fmt.Errorf("failed to get mac address: %w", err) @@ -26,9 +38,21 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er guid := strings.ReplaceAll(strings.ToUpper(macAddr), ":", "") - req := t.listVersionsRequest(input.Account, input.App, guid) - res, err := t.downloadClient.Send(req) + var externalVersionID string + + switch platform { + case PlatformMacOS: + externalVersionID, err = t.lookupLatestMacOSExternalVersionID(input.Account, input.App) + case PlatformAppleTV, PlatformVisionOS: + externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, platform) + } + if err != nil { + return ListVersionsOutput{}, fmt.Errorf("failed to resolve platform version: %w", err) + } + + req := t.listVersionsRequest(input.Account, input.App, guid, externalVersionID) + res, err := t.downloadClient.Send(req) if err != nil { return ListVersionsOutput{}, fmt.Errorf("failed to send http request: %w", err) } @@ -71,7 +95,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er if len(res.Data.Items) == 0 { // Try redownload endpoint as fallback - redownloadReq := t.redownloadRequest(input.Account, input.App, guid, "") + redownloadReq := t.redownloadRequest(input.Account, input.App, guid, externalVersionID) redownloadRes, redownloadErr := t.downloadClient.Send(redownloadReq) if redownloadErr != nil { return ListVersionsOutput{}, fmt.Errorf("both endpoints failed: primary=invalid response, redownload=%w", redownloadErr) @@ -109,7 +133,7 @@ func (t *appstore) ListVersions(input ListVersionsInput) (ListVersionsOutput, er }, nil } -func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.Request { +func (t *appstore) listVersionsRequest(acc Account, app App, guid, externalVersionID string) http.Request { payload := map[string]interface{}{ "creditDisplay": "", "guid": guid, @@ -117,6 +141,10 @@ func (t *appstore) listVersionsRequest(acc Account, app App, guid string) http.R "serialNumber": "0", } + if externalVersionID != "" { + payload["externalVersionId"] = externalVersionID + } + podPrefix := "" if acc.Pod != "" { podPrefix = "p" + acc.Pod + "-" diff --git a/pkg/appstore/appstore_list_versions_test.go b/pkg/appstore/appstore_list_versions_test.go index 7b4fb67..43809a5 100644 --- a/pkg/appstore/appstore_list_versions_test.go +++ b/pkg/appstore/appstore_list_versions_test.go @@ -2,6 +2,7 @@ package appstore import ( "errors" + gohttp "net/http" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/util/machine" @@ -32,6 +33,42 @@ var _ = Describe("AppStore (ListVersions)", func() { ctrl.Finish() }) + It("pins the Mac offer before requesting a universal app's version history", func() { + pages := http.NewMockClient[[]byte](ctrl) + as.(*appstore).storefrontClient = pages + mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + gomock.InOrder( + pages.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.URL).To(Equal("https://apps.apple.com/de/app/id6472431552?platform=mac")) + }).Return(http.Result[[]byte]{StatusCode: gohttp.StatusOK, Data: macVersionPage(karingMacConfiguration)}, nil), + mockDownloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.URL).To(ContainSubstring("volumeStoreDownloadProduct")) + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", "876660716")) + }).Return(http.Result[downloadResult]{StatusCode: gohttp.StatusOK, Data: downloadResult{Items: []downloadItemResult{{Metadata: map[string]interface{}{ + "softwareVersionExternalIdentifiers": []interface{}{uint64(876660700), uint64(876660716)}, + "softwareVersionExternalIdentifier": uint64(876660716), + }}}}}, nil), + ) + out, err := as.ListVersions(ListVersionsInput{Account: Account{StoreFront: "143443-2,34"}, App: App{ID: 6472431552, BundleID: "com.nebula.karing"}, Platform: PlatformMacOS}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.ExternalVersionIdentifiers).To(Equal([]string{"876660700", "876660716"})) + Expect(out.LatestExternalVersionID).To(Equal("876660716")) + }) + + It("does not fall back to iOS when the Mac version cannot be resolved", func() { + pages := http.NewMockClient[[]byte](ctrl) + as.(*appstore).storefrontClient = pages + mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + pages.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: gohttp.StatusOK, Data: macVersionPage(`{}`)}, nil) + _, err := as.ListVersions(ListVersionsInput{Account: Account{StoreFront: "143443-2,34"}, App: App{ID: 42}, Platform: PlatformMacOS}) + Expect(err).To(MatchError(ContainSubstring("failed to resolve platform version"))) + }) + + It("rejects unsupported platforms before making requests", func() { + _, err := as.ListVersions(ListVersionsInput{Platform: PlatformUnknown}) + Expect(err).To(MatchError(`invalid platform "unknown"`)) + }) + When("fails to get MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). From 4e98a8e2a9833db0ce2b99788972ebbbe750bf9e Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 17:00:54 +0200 Subject: [PATCH 03/18] feat: support macos version metadata Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cmd/get_version_metadata.go | 18 +++++-- cmd/get_version_metadata_test.go | 48 +++++++++++++++++++ pkg/appstore/appstore_get_version_metadata.go | 15 +++++- 3 files changed, 76 insertions(+), 5 deletions(-) create mode 100644 cmd/get_version_metadata_test.go diff --git a/cmd/get_version_metadata.go b/cmd/get_version_metadata.go index d2eaff8..98b9071 100644 --- a/cmd/get_version_metadata.go +++ b/cmd/get_version_metadata.go @@ -16,6 +16,7 @@ func getVersionMetadataCmd() *cobra.Command { appID int64 bundleID string externalVersionID string + platformValue string ) cmd := &cobra.Command{ @@ -26,6 +27,11 @@ func getVersionMetadataCmd() *cobra.Command { return errors.New("either the app ID or the bundle identifier must be specified") } + platform, err := appstore.ParsePlatform(platformValue) + if err != nil { + return err + } + var lastErr error var acc appstore.Account @@ -57,7 +63,7 @@ func getVersionMetadataCmd() *cobra.Command { app := appstore.App{ID: appID} if bundleID != "" { - lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID}) + lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{Account: acc, BundleID: bundleID, Platform: platform}) if err != nil { return err } @@ -66,9 +72,11 @@ func getVersionMetadataCmd() *cobra.Command { } out, err := dependencies.AppStore.GetVersionMetadata(appstore.GetVersionMetadataInput{ + Context: cmd.Context(), Account: acc, App: app, VersionID: externalVersionID, + Platform: platform, }) if err != nil { return err @@ -97,9 +105,11 @@ func getVersionMetadataCmd() *cobra.Command { }, } - cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") - cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") - cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (required)") + cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target app (required)") + cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target app (overrides the app ID)") + cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target app (required)") + + cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to retrieve metadata for: iphone (iOS), ipad (iPadOS), appletv (tvOS), visionos, or macos") _ = cmd.MarkFlagRequired("external-version-id") diff --git a/cmd/get_version_metadata_test.go b/cmd/get_version_metadata_test.go new file mode 100644 index 0000000..7d915cc --- /dev/null +++ b/cmd/get_version_metadata_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Get Version Metadata command", func() { + DescribeTable("passes the platform and exact version to metadata lookup", func(args []string, platform appstore.Platform, lookup bool) { + store := &fakeVersionMetadataAppStore{} + previous := dependencies + DeferCleanup(func() { dependencies = previous }) + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + cmd := getVersionMetadataCmd() + cmd.SetArgs(append(args, "--external-version-id", "876660716")) + Expect(cmd.Execute()).To(Succeed()) + Expect(store.input.Platform).To(Equal(platform)) + Expect(store.input.VersionID).To(Equal("876660716")) + Expect(store.input.App.ID).To(Equal(int64(42))) + Expect(store.lookupCalled).To(Equal(lookup)) + if lookup { + Expect(store.lookupInput.Platform).To(Equal(platform)) + } + }, + Entry("Mac bundle", []string{"-b", "com.example.mac", "--platform", "macos"}, appstore.PlatformMacOS, true), + Entry("Mac ID alias", []string{"-i", "42", "--platform", "mac"}, appstore.PlatformMacOS, false), + Entry("default", []string{"-i", "42"}, appstore.Platform(""), false), + ) + It("rejects unknown as a filter", func() { + cmd := getVersionMetadataCmd() + cmd.SetArgs([]string{"-i", "42", "--external-version-id", "123", "--platform", "unknown"}) + Expect(cmd.Execute()).To(MatchError(`invalid platform "unknown"`)) + }) +}) + +type fakeVersionMetadataAppStore struct { + fakeListVersionsAppStore + input appstore.GetVersionMetadataInput +} + +func (s *fakeVersionMetadataAppStore) GetVersionMetadata(input appstore.GetVersionMetadataInput) (appstore.GetVersionMetadataOutput, error) { + s.input = input + + return appstore.GetVersionMetadataOutput{}, nil +} diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index baf78ba..db23c37 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -1,6 +1,7 @@ package appstore import ( + "context" "errors" "fmt" "strings" @@ -10,9 +11,11 @@ import ( ) type GetVersionMetadataInput struct { + Context context.Context Account Account App App VersionID string + Platform Platform } type GetVersionMetadataOutput struct { @@ -22,6 +25,17 @@ type GetVersionMetadataOutput struct { } func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersionMetadataOutput, error) { + platform := input.Platform + if platform == "" { + platform = PlatformIPhone + } + + switch platform { + case PlatformIPhone, PlatformIPad, PlatformAppleTV, PlatformVisionOS, PlatformMacOS: + default: + return GetVersionMetadataOutput{}, fmt.Errorf("invalid platform %q", platform) + } + macAddr, err := t.machine.MacAddress() if err != nil { return GetVersionMetadataOutput{}, fmt.Errorf("failed to get mac address: %w", err) @@ -31,7 +45,6 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion req := t.getVersionMetadataRequest(input.Account, input.App, guid, input.VersionID) res, err := t.downloadClient.Send(req) - if err != nil { return GetVersionMetadataOutput{}, fmt.Errorf("failed to send http request: %w", err) } From a71f14fe2eb3a803c8b579632f064c461292e9fd Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 21:45:28 +0200 Subject: [PATCH 04/18] fix: handle downloads without sinf data --- pkg/appstore/appstore_replicate_sinf.go | 6 +++ pkg/appstore/appstore_replicate_sinf_test.go | 54 ++++++++++++++++++++ 2 files changed, 60 insertions(+) diff --git a/pkg/appstore/appstore_replicate_sinf.go b/pkg/appstore/appstore_replicate_sinf.go index 21f919c..afa0376 100644 --- a/pkg/appstore/appstore_replicate_sinf.go +++ b/pkg/appstore/appstore_replicate_sinf.go @@ -53,6 +53,12 @@ func (t *appstore) ReplicateSinf(input ReplicateSinfInput) error { return fmt.Errorf("failed to replicate zip: %w", err) } + // Device-based downloads can omit sinfs even when the package has a + // manifest. Preserve the archive rewrite, but only inject supplied data. + if len(input.Sinfs) == 0 { + return nil + } + bundleName, err := t.readBundleName(zipReader) if err != nil { return fmt.Errorf("failed to read bundle name: %w", err) diff --git a/pkg/appstore/appstore_replicate_sinf_test.go b/pkg/appstore/appstore_replicate_sinf_test.go index daeeaa1..08c8876 100644 --- a/pkg/appstore/appstore_replicate_sinf_test.go +++ b/pkg/appstore/appstore_replicate_sinf_test.go @@ -6,6 +6,7 @@ import ( "fmt" "io" "os" + "path/filepath" "github.com/majd/ipatool/v2/pkg/http" "github.com/majd/ipatool/v2/pkg/keychain" @@ -335,3 +336,56 @@ var _ = Describe("AppStore (ReplicateSinf)", func() { }) }) }) + +var _ = Describe("SINF replication with optional license data", func() { + DescribeTable("preserves package contents when Apple supplies no sinfs", func(withManifest bool, sinfs []Sinf) { + packagePath := filepath.Join(GinkgoT().TempDir(), "app.ipa") + file, err := os.Create(packagePath) + Expect(err).ToNot(HaveOccurred()) + writer := zip.NewWriter(file) + info, err := plist.Marshal(packageInfo{BundleExecutable: "Test"}, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + contents := map[string][]byte{ + "Payload/Test.app/Info.plist": info, + "Payload/Test.app/Test": []byte("executable"), + "Payload/Test.app/SC_Info/Test.supf": []byte("existing protection data"), + } + if withManifest { + manifest, err := plist.Marshal(packageManifest{SinfPaths: []string{"SC_Info/Test.sinf"}}, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + contents["Payload/Test.app/SC_Info/Manifest.plist"] = manifest + } + for name, data := range contents { + entry, err := writer.Create(name) + Expect(err).ToNot(HaveOccurred()) + _, err = entry.Write(data) + Expect(err).ToNot(HaveOccurred()) + } + Expect(writer.Close()).To(Succeed()) + Expect(file.Close()).To(Succeed()) + + store := &appstore{os: operatingsystem.New()} + Expect(store.ReplicateSinf(ReplicateSinfInput{PackagePath: packagePath, Sinfs: sinfs})).To(Succeed()) + + reader, err := zip.OpenReader(packagePath) + Expect(err).ToNot(HaveOccurred()) + DeferCleanup(reader.Close) + Expect(reader.File).To(HaveLen(len(contents))) + for _, entry := range reader.File { + Expect(contents).To(HaveKey(entry.Name)) + src, err := entry.Open() + Expect(err).ToNot(HaveOccurred()) + data, err := io.ReadAll(src) + Expect(err).ToNot(HaveOccurred()) + Expect(src.Close()).To(Succeed()) + Expect(data).To(Equal(contents[entry.Name])) + } + _, err = os.Stat(packagePath + ".tmp") + Expect(os.IsNotExist(err)).To(BeTrue()) + }, + Entry("with a manifest and omitted sinfs", true, []Sinf(nil)), + Entry("with a manifest and empty sinfs", true, []Sinf{}), + Entry("without a manifest and omitted sinfs", false, []Sinf(nil)), + Entry("without a manifest and empty sinfs", false, []Sinf{}), + ) +}) From 911ee4d914814bd87ffa51489f99ae36a86d15f7 Mon Sep 17 00:00:00 2001 From: leoca Date: Sun, 13 Sep 2026 17:47:42 +0200 Subject: [PATCH 05/18] fix: report zip finalization errors when applying patches --- pkg/appstore/appstore_download.go | 16 ++++- .../appstore_download_patches_test.go | 71 +++++++++++++++++++ 2 files changed, 84 insertions(+), 3 deletions(-) create mode 100644 pkg/appstore/appstore_download_patches_test.go diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index a1e5fc9..6df81dd 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -663,7 +663,8 @@ func (t *appstore) isDirectory(path string) (bool, error) { return info.IsDir(), nil } -func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string, artwork []byte) error { +//nolint:nonamedreturns // Deferred close errors must propagate to callers. +func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst string, artwork []byte) (err error) { srcZip, err := zip.OpenReader(src) if err != nil { return fmt.Errorf("failed to open zip reader: %w", err) @@ -674,10 +675,19 @@ func (t *appstore) applyPatches(item downloadItemResult, acc Account, src, dst s if err != nil { return fmt.Errorf("failed to open file: %w", err) } - defer dstFile.Close() + + defer func() { + if closeErr := dstFile.Close(); closeErr != nil { + err = joinCleanupError(err, "failed to close patched file", closeErr) + } + }() dstZip := zip.NewWriter(dstFile) - defer dstZip.Close() + defer func() { + if closeErr := dstZip.Close(); closeErr != nil { + err = joinCleanupError(err, "failed to close zip writer", closeErr) + } + }() err = t.replicateZip(srcZip, dstZip, src) if err != nil { diff --git a/pkg/appstore/appstore_download_patches_test.go b/pkg/appstore/appstore_download_patches_test.go new file mode 100644 index 0000000..ed8a419 --- /dev/null +++ b/pkg/appstore/appstore_download_patches_test.go @@ -0,0 +1,71 @@ +package appstore + +import ( + "archive/zip" + "fmt" + "os" + "path/filepath" + "testing" + + "github.com/majd/ipatool/v2/pkg/util/operatingsystem" +) + +type readOnlyDestinationOS struct { + operatingsystem.OperatingSystem + destination string +} + +func (o readOnlyDestinationOS) OpenFile(name string, flag int, perm os.FileMode) (*os.File, error) { + if name != o.destination { + file, err := o.OperatingSystem.OpenFile(name, flag, perm) + if err != nil { + return nil, fmt.Errorf("failed to open file: %w", err) + } + + return file, nil + } + + if err := os.WriteFile(name, nil, perm); err != nil { + return nil, fmt.Errorf("failed to create destination: %w", err) + } + + // Hand back a read-only descriptor so every write to the archive fails. + file, err := os.Open(name) + if err != nil { + return nil, fmt.Errorf("failed to open destination: %w", err) + } + + return file, nil +} + +func TestApplyPatchesReportsZipWriteFailure(t *testing.T) { + dir := t.TempDir() + src, dst := filepath.Join(dir, "source.zip"), filepath.Join(dir, "patched.ipa") + + file, err := os.Create(src) + if err != nil { + t.Fatal(err) + } + + writer := zip.NewWriter(file) + if _, err := writer.Create("Payload/App.app/"); err != nil { + t.Fatal(err) + } + + if err := writer.Close(); err != nil { + t.Fatal(err) + } + + if err := file.Close(); err != nil { + t.Fatal(err) + } + + // The patched archive is small enough to stay buffered until the zip writer + // is closed, so the write failure only surfaces when the archive is finalized. + store := &appstore{os: readOnlyDestinationOS{OperatingSystem: operatingsystem.New(), destination: dst}} + + err = store.applyPatches(downloadItemResult{Metadata: map[string]interface{}{}}, Account{}, src, dst, nil) + if err == nil { + t.Fatal("expected applyPatches to report that the patched archive could not be written") + } +} From 3826230b98d597844d783ef4231446dbee0b03aa Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 21:51:18 +0200 Subject: [PATCH 06/18] test: strengthen patch finalization regression coverage --- .../appstore_download_patches_test.go | 55 +++++++++---------- 1 file changed, 25 insertions(+), 30 deletions(-) diff --git a/pkg/appstore/appstore_download_patches_test.go b/pkg/appstore/appstore_download_patches_test.go index ed8a419..5d2e93b 100644 --- a/pkg/appstore/appstore_download_patches_test.go +++ b/pkg/appstore/appstore_download_patches_test.go @@ -2,12 +2,15 @@ package appstore import ( "archive/zip" + "bytes" + "errors" "fmt" "os" "path/filepath" - "testing" "github.com/majd/ipatool/v2/pkg/util/operatingsystem" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" ) type readOnlyDestinationOS struct { @@ -38,34 +41,26 @@ func (o readOnlyDestinationOS) OpenFile(name string, flag int, perm os.FileMode) return file, nil } -func TestApplyPatchesReportsZipWriteFailure(t *testing.T) { - dir := t.TempDir() - src, dst := filepath.Join(dir, "source.zip"), filepath.Join(dir, "patched.ipa") +var _ = Describe("AppStore (patch finalization)", func() { + It("reports buffered writes that fail when closing the ZIP writer", func() { + dir := GinkgoT().TempDir() + src, dst := filepath.Join(dir, "source.zip"), filepath.Join(dir, "patched.ipa") - file, err := os.Create(src) - if err != nil { - t.Fatal(err) - } - - writer := zip.NewWriter(file) - if _, err := writer.Create("Payload/App.app/"); err != nil { - t.Fatal(err) - } - - if err := writer.Close(); err != nil { - t.Fatal(err) - } + var source bytes.Buffer + writer := zip.NewWriter(&source) + _, err := writer.Create("Payload/App.app/") + Expect(err).ToNot(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + Expect(os.WriteFile(src, source.Bytes(), 0600)).To(Succeed()) - if err := file.Close(); err != nil { - t.Fatal(err) - } - - // The patched archive is small enough to stay buffered until the zip writer - // is closed, so the write failure only surfaces when the archive is finalized. - store := &appstore{os: readOnlyDestinationOS{OperatingSystem: operatingsystem.New(), destination: dst}} - - err = store.applyPatches(downloadItemResult{Metadata: map[string]interface{}{}}, Account{}, src, dst, nil) - if err == nil { - t.Fatal("expected applyPatches to report that the patched archive could not be written") - } -} + // The patched archive stays buffered until Close, where the read-only + // destination descriptor rejects the write. + store := &appstore{os: readOnlyDestinationOS{OperatingSystem: operatingsystem.New(), destination: dst}} + err = store.applyPatches(downloadItemResult{Metadata: map[string]interface{}{}}, Account{}, src, dst, nil) + Expect(err).To(MatchError(ContainSubstring("failed to close zip writer"))) + var pathErr *os.PathError + Expect(errors.As(err, &pathErr)).To(BeTrue()) + Expect(pathErr.Op).To(Equal("write")) + Expect(pathErr.Path).To(Equal(dst)) + }) +}) From 197dd72542de151bb156b92913c8755037ea84ac Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 22:13:11 +0200 Subject: [PATCH 07/18] feat: include platforms in search results Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_search.go | 61 ++++++++++++++++++++++++++++ pkg/appstore/appstore_search_test.go | 22 ++++++++++ 2 files changed, 83 insertions(+) diff --git a/pkg/appstore/appstore_search.go b/pkg/appstore/appstore_search.go index 015bfd1..3114536 100644 --- a/pkg/appstore/appstore_search.go +++ b/pkg/appstore/appstore_search.go @@ -1,10 +1,12 @@ package appstore import ( + "encoding/json" "errors" "fmt" gohttp "net/http" "net/url" + "slices" "strconv" "github.com/majd/ipatool/v2/pkg/http" @@ -53,6 +55,65 @@ type searchResult struct { Results []App `json:"results,omitempty"` } +// UnmarshalJSON derives platforms from catalog metadata without exposing the +// catalog's device list in app output. +func (r *searchResult) UnmarshalJSON(data []byte) error { + var result struct { + Count int `json:"resultCount"` + Results []struct { + App + Kind string `json:"kind"` + SupportedDevices []string `json:"supportedDevices"` + } `json:"results"` + } + + if err := json.Unmarshal(data, &result); err != nil { + return fmt.Errorf("failed to decode search results: %w", err) + } + + r.Count = result.Count + r.Results = nil + + if result.Results != nil { + r.Results = make([]App, 0, len(result.Results)) + } + + for _, item := range result.Results { + platforms := item.Platforms + + for _, family := range []struct { + prefix string + platform Platform + }{ + {"iPhone", PlatformIPhone}, + {"iPod", PlatformIPhone}, + {"iPad", PlatformIPad}, + {"AppleTV", PlatformAppleTV}, + {"RealityDevice", PlatformVisionOS}, + {"Mac", PlatformMacOS}, + } { + for _, device := range item.SupportedDevices { + if strings.HasPrefix(device, family.prefix) && !slices.Contains(platforms, family.platform) { + platforms = append(platforms, family.platform) + } + } + } + + if item.Kind == "mac-software" && !slices.Contains(platforms, PlatformMacOS) { + platforms = append(platforms, PlatformMacOS) + } + + if len(platforms) == 0 { + platforms = []Platform{PlatformUnknown} + } + + item.Platforms = platforms + r.Results = append(r.Results, item.App) + } + + return nil +} + func (t *appstore) searchRequest(term, countryCode string, limit int64, platform Platform) (http.Request, error) { url, err := t.searchURL(term, countryCode, limit, platform) if err != nil { diff --git a/pkg/appstore/appstore_search_test.go b/pkg/appstore/appstore_search_test.go index 635d567..151b5e7 100644 --- a/pkg/appstore/appstore_search_test.go +++ b/pkg/appstore/appstore_search_test.go @@ -1,6 +1,7 @@ package appstore import ( + "encoding/json" "errors" "net/url" @@ -29,6 +30,27 @@ var _ = Describe("AppStore (Search)", func() { ctrl.Finish() }) + DescribeTable("decodes catalog platforms", + func(metadata string, expected []Platform) { + var result searchResult + Expect(json.Unmarshal([]byte(`{"resultCount":1,"results":[{"trackId":42,`+metadata+`}]}`), &result)).To(Succeed()) + mockClient.EXPECT().Send(gomock.Any()).Return(http.Result[searchResult]{StatusCode: 200, Data: result}, nil) + out, err := as.Search(SearchInput{Account: Account{StoreFront: "143441"}}) + Expect(err).ToNot(HaveOccurred()) + Expect(out.Count).To(Equal(1)) + Expect(out.Results).To(Equal([]App{{ID: 42, Platforms: expected}})) + }, + Entry("universal app with duplicate devices", `"supportedDevices":["iPadAir-iPadAir","iPhone5s-iPhone5s","iPhone6-iPhone6","iPodTouchSixthGen-iPodTouchSixthGen"]`, []Platform{PlatformIPhone, PlatformIPad}), + Entry("iPad only", `"supportedDevices":["iPadAir-iPadAir"]`, []Platform{PlatformIPad}), + Entry("iPod", `"supportedDevices":["iPodTouchSixthGen-iPodTouchSixthGen"]`, []Platform{PlatformIPhone}), + Entry("TV", `"supportedDevices":["AppleTV4-AppleTV4"]`, []Platform{PlatformAppleTV}), + Entry("vision", `"supportedDevices":["RealityDevice-RealityDevice"]`, []Platform{PlatformVisionOS}), + Entry("Mac", `"kind":"mac-software"`, []Platform{PlatformMacOS}), + Entry("multiple families", `"supportedDevices":["MacDesktop-MacDesktop","RealityDevice-RealityDevice","iPadAir-iPadAir"]`, []Platform{PlatformIPad, PlatformVisionOS, PlatformMacOS}), + Entry("missing metadata", `"kind":"software"`, []Platform{PlatformUnknown}), + Entry("unrecognized devices", `"supportedDevices":["FutureDevice"]`, []Platform{PlatformUnknown}), + ) + When("request is successful", func() { const ( testID = 0 From 807242ba0a96ae2217937dbd29f4ff47d9f07bdd Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 22:25:12 +0200 Subject: [PATCH 08/18] fix: select macos packages for universal apps Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_download_test.go | 210 ++++++++++++++++++ .../appstore_macos_version_lookup_test.go | 4 +- 2 files changed, 212 insertions(+), 2 deletions(-) diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 5285f78..9f42b42 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -696,9 +696,122 @@ var _ = Describe("AppStore (Download)", func() { return file.Name() } +<<<<<<< HEAD It("accepts AppleTVOS packages", func() { path := writePackage([]string{"AppleTVOS"}) defer os.Remove(path) +======= + store := &appstore{ + downloadClient: mockDownloadClient, + bagClient: mockBagClient, + platformClient: mockPlatformClient, + httpClient: mockHTTPClient, + machine: mockMachine, + os: operatingsystem.New(), + } + out, err := store.Download(DownloadInput{ + Account: Account{StoreFront: "143441", Email: "test@example.com"}, + App: App{ID: 42}, + OutputPath: outputPath, + Platform: platform, + ExternalVersionID: versionID, + }) + if succeeds { + Expect(err).ToNot(HaveOccurred()) + Expect(out.DestinationPath).To(Equal(outputPath)) + actual, readErr := os.ReadFile(outputPath) + Expect(readErr).ToNot(HaveOccurred()) + Expect(actual).ToNot(Equal(previousOutput)) + } else { + Expect(err).To(MatchError(ContainSubstring("failed to validate package platform"))) + Expect(out.DestinationPath).To(BeEmpty()) + Expect(os.ReadFile(outputPath)).To(Equal(previousOutput)) + } + Expect(outputPath + ".tmp").ToNot(BeAnExistingFile()) + }, + Entry("iPhone accepts iOS", PlatformIPhone, "", "", "iPhoneOS", true), + Entry("iPad accepts iOS", PlatformIPad, "", "", "iPhoneOS", true), + Entry("iPhone rejects tvOS", PlatformIPhone, "", "", "AppleTVOS", false), + Entry("iPad rejects tvOS", PlatformIPad, "", "", "AppleTVOS", false), + Entry("iPhone rejects visionOS", PlatformIPhone, "", "", "XROS", false), + Entry("a conflicting explicit version is rejected, not replaced", PlatformIPhone, "818970197", "", "AppleTVOS", false), + Entry("default iOS fallback accepts iOS", Platform(""), "", "redownload", "iPhoneOS", true), + Entry("default iOS fallback rejects tvOS", Platform(""), "", "redownload", "AppleTVOS", false), + Entry("latest update accepts iOS", Platform(""), "", "update", "iPhoneOS", true), + Entry("pinned update accepts iOS", Platform(""), "818970197", "update", "iPhoneOS", true), + Entry("pinned update rejects tvOS", Platform(""), "818970197", "update", "AppleTVOS", false), + Entry("pinned redownload preserves an unspecified platform", Platform(""), "818970197", "redownload", "AppleTVOS", true), + Entry("normal unspecified-platform behavior is preserved", Platform(""), "", "", "AppleTVOS", true), + Entry("an explicit tvOS version without a platform is preserved", Platform(""), "818970197", "", "AppleTVOS", true), + Entry("explicit tvOS downloads still work", PlatformAppleTV, "818970197", "", "AppleTVOS", true), + ) + + Describe("macOS packages", func() { + It("decrypts the downloaded package with in-memory machine identity and dpInfo", func() { + tempDir := GinkgoT().TempDir() + requestedPath := filepath.Join(tempDir, "custom-output.pkg") + packageData := []byte("encrypted package") + decryptedData := makeTestXAR([]byte("decrypted payload"), false) + dpInfo := bytes.Repeat([]byte{0x42}, 88) + decrypter := &fakeMacPackageDecrypter{output: decryptedData} + + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:aa:bb:cc", nil) + + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Do(func(req http.Request) { + payload := req.Payload.(*http.XMLPayload) + Expect(payload.Content["guid"]).To(Equal("001122AABBCC")) + }). + Return(http.Result[downloadResult]{ + StatusCode: 200, + Data: downloadResult{ + Items: []downloadItemResult{{ + URL: "https://example.test/app.pkg", + Sinfs: []Sinf{{DPInfo: dpInfo}}, + Metadata: map[string]interface{}{ + "bundleShortVersionString": "1.2.3", + "software-platform": "macos", + "product-type": "ios-app", + }, + }}, + }, + }, nil) + + mockHTTPClient.EXPECT(). + NewRequest("GET", "https://example.test/app.pkg", nil). + Return(&gohttp.Request{Header: gohttp.Header{}}, nil) + mockHTTPClient.EXPECT(). + Do(gomock.Any()). + Return(&gohttp.Response{ + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(bytes.NewReader(packageData)), + ContentLength: int64(len(packageData)), + }, nil) + + store := &appstore{ + downloadClient: mockDownloadClient, + httpClient: mockHTTPClient, + machine: mockMachine, + os: operatingsystem.New(), + macDecrypterFactory: func(ctx context.Context, hardwareID, gotDPInfo []byte) (macPackageDecrypter, error) { + Expect(ctx).ToNot(BeNil()) + Expect(hardwareID).To(Equal([]byte{0x00, 0x11, 0x22, 0xaa, 0xbb, 0xcc})) + Expect(gotDPInfo).To(Equal(dpInfo)) + + return decrypter, nil + }, + } + out, err := store.Download(DownloadInput{ + Context: context.Background(), + App: App{ID: 42, BundleID: "com.example.mac"}, + OutputPath: requestedPath, + Platform: PlatformMacOS, + ExternalVersionID: "123456789", + }) +>>>>>>> c66d653 (fix: select macos packages for universal apps) err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) Expect(err).ToNot(HaveOccurred()) @@ -708,9 +821,106 @@ var _ = Describe("AppStore (Download)", func() { path := writePackage([]string{"iPhoneOS"}) defer os.Remove(path) +<<<<<<< HEAD err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("AppleTVOS")) +======= + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:aa:bb:cc", nil) + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[downloadResult]{ + StatusCode: 200, + Data: downloadResult{ + Items: []downloadItemResult{{ + URL: "https://example.test/developer.ipa", + Sinfs: []Sinf{{ + ID: 0, + Data: []byte("mobile sinf"), + }}, + Metadata: map[string]interface{}{ + "bundleShortVersionString": "11.0.2", + "software-platform": "ios", + "product-type": "ios-app", + }, + }}, + }, + }, nil) + mockHTTPClient.EXPECT(). + NewRequest("GET", "https://example.test/developer.ipa", nil). + Return(&gohttp.Request{Header: gohttp.Header{}}, nil) + mockHTTPClient.EXPECT(). + Do(gomock.Any()). + Return(&gohttp.Response{ + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(bytes.NewReader(packageBuffer.Bytes())), + ContentLength: int64(packageBuffer.Len()), + }, nil) + + store := &appstore{ + downloadClient: mockDownloadClient, + httpClient: mockHTTPClient, + machine: mockMachine, + os: operatingsystem.New(), + macDecrypterFactory: func(context.Context, []byte, []byte) (macPackageDecrypter, error) { + Fail("mobile packages must not initialize the macOS package decrypter") + + return nil, nil + }, + } + + out, err := store.Download(DownloadInput{ + Context: context.Background(), + Account: Account{Email: "test@example.com"}, + App: App{ID: 640199958, BundleID: "developer.apple.wwdc-Release"}, + OutputPath: tempDir, + Platform: PlatformMacOS, + ExternalVersionID: "123456789", + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(out.DestinationPath).To(Equal(requestedPath)) + Expect(out.Sinfs).To(Equal([]Sinf{{ID: 0, Data: []byte("mobile sinf")}})) + Expect(requestedPath).To(BeAnExistingFile()) + Expect(requestedPath + macEncryptedStageSuffix).ToNot(BeAnExistingFile()) + Expect(requestedPath + macDecryptedStageSuffix).ToNot(BeAnExistingFile()) + }) + + It("uses a pkg name derived from the generated package name", func() { + store := &appstore{os: operatingsystem.New()} + tempDir := GinkgoT().TempDir() + + packagePath, err := store.resolveDestinationPath( + App{ID: 42, BundleID: "com.example.mac"}, + "1.2.3", + tempDir, + PlatformMacOS, + ) + Expect(err).ToNot(HaveOccurred()) + Expect(packagePath).To(Equal(filepath.Join(tempDir, "com.example.mac_42_1.2.3.pkg"))) + }) + + It("preserves an explicit output path", func() { + store := &appstore{os: operatingsystem.New()} + tempDir := GinkgoT().TempDir() + requestedPath := filepath.Join(tempDir, "custom-output") + + packagePath, err := store.resolveDestinationPath(App{}, "1.2.3", requestedPath, PlatformMacOS) + Expect(err).ToNot(HaveOccurred()) + Expect(packagePath).To(Equal(requestedPath)) + }) + + It("rejects a download response without dpInfo", func() { + _, err := macDPInfo(nil) + Expect(err).To(MatchError(ContainSubstring("dpInfo"))) + }) + + It("rejects conflicting dpInfo values", func() { + _, err := macDPInfo([]Sinf{{DPInfo: []byte("one")}, {DPInfo: []byte("two")}}) + Expect(err).To(MatchError(ContainSubstring("conflicting"))) +>>>>>>> c66d653 (fix: select macos packages for universal apps) }) }) diff --git a/pkg/appstore/appstore_macos_version_lookup_test.go b/pkg/appstore/appstore_macos_version_lookup_test.go index 08ddedd..3d8a2ec 100644 --- a/pkg/appstore/appstore_macos_version_lookup_test.go +++ b/pkg/appstore/appstore_macos_version_lookup_test.go @@ -42,9 +42,9 @@ var _ = Describe("Mac purchase version selection", func() { ctrl := gomock.NewController(GinkgoT()) defer ctrl.Finish() pages := http.NewMockClient[[]byte](ctrl) - store := &appstore{storefrontClient: pages} + store := &appstore{storefrontClient: pages, downloadClient: http.NewMockClient[downloadResult](ctrl)} pages.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: status, Data: body}, requestErr) - _, err := store.lookupLatestMacOSExternalVersionID(Account{StoreFront: "143443-2,34"}, app) + _, _, err := store.sendDownloadProduct(Account{StoreFront: "143443-2,34"}, app, "001122334455", "", PlatformMacOS) Expect(err).To(HaveOccurred()) }, Entry("HTTP failure", 500, nil, nil), From bb0d2350b3909da3ce8c75e56249a05713d4917a Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 23:04:41 +0200 Subject: [PATCH 09/18] feat: add ios builds for jailbroken devices Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .github/workflows/dry-build.yml | 11 ++ .github/workflows/release.yml | 42 +++++ cmd/common.go | 2 +- cmd/keyring_default.go | 12 ++ cmd/keyring_ios.go | 48 ++++++ cmd/keyring_ios_test.go | 27 ++++ go.mod | 3 +- pkg/appstore/appstore_download_test.go | 210 ------------------------- resources/ios-entitlements.plist | 14 ++ tools/build-ios.sh | 42 +++++ tools/patches/unicorn-ios.patch | 76 +++++++++ 11 files changed, 275 insertions(+), 212 deletions(-) create mode 100644 cmd/keyring_default.go create mode 100644 cmd/keyring_ios.go create mode 100644 cmd/keyring_ios_test.go create mode 100644 resources/ios-entitlements.plist create mode 100755 tools/build-ios.sh create mode 100644 tools/patches/unicorn-ios.patch diff --git a/.github/workflows/dry-build.yml b/.github/workflows/dry-build.yml index dadfb22..c20ef39 100644 --- a/.github/workflows/dry-build.yml +++ b/.github/workflows/dry-build.yml @@ -66,3 +66,14 @@ jobs: CGO_ENABLED: 1 CGO_CFLAGS: -mmacosx-version-min=10.15 CGO_LDFLAGS: -mmacosx-version-min=10.15 + build_ios: + name: Build for iOS + runs-on: macos-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + cache: true + - run: brew install ldid cmake + - run: ./tools/build-ios.sh diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 3ce0a73..2474870 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -130,6 +130,48 @@ jobs: name: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} path: ipatool-${{ needs.get_version.outputs.version }}-linux-${{ matrix.arch }} if-no-files-found: error + build_ios: + name: Build for iOS + runs-on: macos-latest + needs: [get_version, test] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v5 + with: + go-version: "1.25.0" + cache: true + - run: brew install ldid cmake + - run: ./tools/build-ios.sh "ipatool-$VERSION-ios-arm64" + env: + VERSION: ${{ needs.get_version.outputs.version }} + - uses: actions/upload-artifact@v4 + with: + name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + path: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + if-no-files-found: error + release_ios: + name: Release for iOS + runs-on: ubuntu-latest + needs: [get_version, build_ios, release_windows] + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + name: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + path: bin + - run: chmod +x "bin/$FILE" && tar -czvf "$FILE.tar.gz" "bin/$FILE" + env: + FILE: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64 + - run: ./tools/sha256sum.sh "$TARBALL" > "$TARBALL.sha256sum" + env: + TARBALL: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.tar.gz + - uses: svenstaro/upload-release-action@v2 + with: + repo_token: ${{ secrets.GITHUB_TOKEN }} + file: ipatool-${{ needs.get_version.outputs.version }}-ios-arm64.* + tag: ${{ github.ref }} + overwrite: false + file_glob: true release_windows: name: Release for Windows runs-on: ubuntu-latest diff --git a/cmd/common.go b/cmd/common.go index 490139d..890723a 100644 --- a/cmd/common.go +++ b/cmd/common.go @@ -171,7 +171,7 @@ func newKeychain(stateDirectory string) keychain.Keychain { util.Must("", err) } - ring := util.Must(keyring.Open(keyring.Config{ + ring := util.Must(openKeyring(keyring.Config{ AllowedBackends: []keyring.BackendType{ keyring.KeychainBackend, keyring.SecretServiceBackend, diff --git a/cmd/keyring_default.go b/cmd/keyring_default.go new file mode 100644 index 0000000..a8ac6ae --- /dev/null +++ b/cmd/keyring_default.go @@ -0,0 +1,12 @@ +//go:build !ios + +package cmd + +import ( + "github.com/byteness/keyring" + "github.com/majd/ipatool/v2/pkg/keychain" +) + +func openKeyring(config keyring.Config) (keychain.Keyring, error) { + return keyring.Open(config) //nolint:wrapcheck +} diff --git a/cmd/keyring_ios.go b/cmd/keyring_ios.go new file mode 100644 index 0000000..85650cc --- /dev/null +++ b/cmd/keyring_ios.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "fmt" + + gokeychain "github.com/byteness/go-keychain" + "github.com/byteness/keyring" + "github.com/majd/ipatool/v2/pkg/keychain" +) + +// iosKeyring fixes the update query in keyring v1.9.0, which includes attributes +// that SecItemUpdate rejects on iOS (kSecMatchLimit and kSecReturnAttributes). +type iosKeyring struct { + keyring.Keyring + service string +} + +func openKeyring(config keyring.Config) (keychain.Keyring, error) { + ring, err := keyring.Open(config) + if err != nil { + return nil, fmt.Errorf("open iOS keyring: %w", err) + } + + return &iosKeyring{Keyring: ring, service: config.ServiceName}, nil +} + +func (k *iosKeyring) Set(item keyring.Item) error { + query := gokeychain.NewItem() + query.SetSecClass(gokeychain.SecClassGenericPassword) + query.SetService(k.service) + query.SetAccount(item.Key) + + attributes := gokeychain.NewItem() + attributes.SetData(item.Data) + attributes.SetLabel(item.Label) + attributes.SetDescription(item.Description) + + err := gokeychain.UpdateItem(query, attributes) + if err == gokeychain.ErrorItemNotFound { + return k.Keyring.Set(item) //nolint:wrapcheck + } + + if err != nil { + return fmt.Errorf("update iOS keyring item: %w", err) + } + + return nil +} diff --git a/cmd/keyring_ios_test.go b/cmd/keyring_ios_test.go new file mode 100644 index 0000000..8dde886 --- /dev/null +++ b/cmd/keyring_ios_test.go @@ -0,0 +1,27 @@ +package cmd + +import ( + "fmt" + "time" + + "github.com/byteness/keyring" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("iOS keyring", func() { + It("persists and updates credentials without invalid SecItemUpdate parameters", func() { + ring, err := openKeyring(keyring.Config{ + AllowedBackends: []keyring.BackendType{keyring.KeychainBackend}, + ServiceName: fmt.Sprintf("ipatool-test-%d", time.Now().UnixNano()), + }) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(func() { Expect(ring.Remove("account")).To(Succeed()) }) + Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("first"), Label: "ipatool"})).To(Succeed()) + Expect(ring.Set(keyring.Item{Key: "account", Data: []byte("updated"), Label: "ipatool"})).To(Succeed()) + item, err := ring.Get("account") + Expect(err).NotTo(HaveOccurred()) + Expect(item.Data).To(Equal([]byte("updated"))) + Expect(item.Label).To(Equal("ipatool")) + }) +}) diff --git a/go.mod b/go.mod index edbfe88..db2a6c9 100644 --- a/go.mod +++ b/go.mod @@ -4,6 +4,7 @@ go 1.25.0 require ( github.com/avast/retry-go v3.0.0+incompatible + github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 github.com/byteness/keyring v1.9.0 github.com/juju/persistent-cookiejar v1.0.0 github.com/onsi/ginkgo/v2 v2.5.0 @@ -22,7 +23,7 @@ require ( require ( github.com/1Password/connect-sdk-go v1.5.4-0.20250417152128-c154b387248b // indirect github.com/1password/onepassword-sdk-go v0.4.1-beta.1 // indirect - github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 // indirect + github.com/byteness/go-libsecret v0.0.0-20260108215642-107379d3dee0 // indirect github.com/byteness/percent v0.2.2 // indirect github.com/danieljoos/wincred v1.2.3 // indirect diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 9f42b42..5285f78 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -696,122 +696,9 @@ var _ = Describe("AppStore (Download)", func() { return file.Name() } -<<<<<<< HEAD It("accepts AppleTVOS packages", func() { path := writePackage([]string{"AppleTVOS"}) defer os.Remove(path) -======= - store := &appstore{ - downloadClient: mockDownloadClient, - bagClient: mockBagClient, - platformClient: mockPlatformClient, - httpClient: mockHTTPClient, - machine: mockMachine, - os: operatingsystem.New(), - } - out, err := store.Download(DownloadInput{ - Account: Account{StoreFront: "143441", Email: "test@example.com"}, - App: App{ID: 42}, - OutputPath: outputPath, - Platform: platform, - ExternalVersionID: versionID, - }) - if succeeds { - Expect(err).ToNot(HaveOccurred()) - Expect(out.DestinationPath).To(Equal(outputPath)) - actual, readErr := os.ReadFile(outputPath) - Expect(readErr).ToNot(HaveOccurred()) - Expect(actual).ToNot(Equal(previousOutput)) - } else { - Expect(err).To(MatchError(ContainSubstring("failed to validate package platform"))) - Expect(out.DestinationPath).To(BeEmpty()) - Expect(os.ReadFile(outputPath)).To(Equal(previousOutput)) - } - Expect(outputPath + ".tmp").ToNot(BeAnExistingFile()) - }, - Entry("iPhone accepts iOS", PlatformIPhone, "", "", "iPhoneOS", true), - Entry("iPad accepts iOS", PlatformIPad, "", "", "iPhoneOS", true), - Entry("iPhone rejects tvOS", PlatformIPhone, "", "", "AppleTVOS", false), - Entry("iPad rejects tvOS", PlatformIPad, "", "", "AppleTVOS", false), - Entry("iPhone rejects visionOS", PlatformIPhone, "", "", "XROS", false), - Entry("a conflicting explicit version is rejected, not replaced", PlatformIPhone, "818970197", "", "AppleTVOS", false), - Entry("default iOS fallback accepts iOS", Platform(""), "", "redownload", "iPhoneOS", true), - Entry("default iOS fallback rejects tvOS", Platform(""), "", "redownload", "AppleTVOS", false), - Entry("latest update accepts iOS", Platform(""), "", "update", "iPhoneOS", true), - Entry("pinned update accepts iOS", Platform(""), "818970197", "update", "iPhoneOS", true), - Entry("pinned update rejects tvOS", Platform(""), "818970197", "update", "AppleTVOS", false), - Entry("pinned redownload preserves an unspecified platform", Platform(""), "818970197", "redownload", "AppleTVOS", true), - Entry("normal unspecified-platform behavior is preserved", Platform(""), "", "", "AppleTVOS", true), - Entry("an explicit tvOS version without a platform is preserved", Platform(""), "818970197", "", "AppleTVOS", true), - Entry("explicit tvOS downloads still work", PlatformAppleTV, "818970197", "", "AppleTVOS", true), - ) - - Describe("macOS packages", func() { - It("decrypts the downloaded package with in-memory machine identity and dpInfo", func() { - tempDir := GinkgoT().TempDir() - requestedPath := filepath.Join(tempDir, "custom-output.pkg") - packageData := []byte("encrypted package") - decryptedData := makeTestXAR([]byte("decrypted payload"), false) - dpInfo := bytes.Repeat([]byte{0x42}, 88) - decrypter := &fakeMacPackageDecrypter{output: decryptedData} - - mockMachine.EXPECT(). - MacAddress(). - Return("00:11:22:aa:bb:cc", nil) - - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Do(func(req http.Request) { - payload := req.Payload.(*http.XMLPayload) - Expect(payload.Content["guid"]).To(Equal("001122AABBCC")) - }). - Return(http.Result[downloadResult]{ - StatusCode: 200, - Data: downloadResult{ - Items: []downloadItemResult{{ - URL: "https://example.test/app.pkg", - Sinfs: []Sinf{{DPInfo: dpInfo}}, - Metadata: map[string]interface{}{ - "bundleShortVersionString": "1.2.3", - "software-platform": "macos", - "product-type": "ios-app", - }, - }}, - }, - }, nil) - - mockHTTPClient.EXPECT(). - NewRequest("GET", "https://example.test/app.pkg", nil). - Return(&gohttp.Request{Header: gohttp.Header{}}, nil) - mockHTTPClient.EXPECT(). - Do(gomock.Any()). - Return(&gohttp.Response{ - StatusCode: gohttp.StatusOK, - Body: io.NopCloser(bytes.NewReader(packageData)), - ContentLength: int64(len(packageData)), - }, nil) - - store := &appstore{ - downloadClient: mockDownloadClient, - httpClient: mockHTTPClient, - machine: mockMachine, - os: operatingsystem.New(), - macDecrypterFactory: func(ctx context.Context, hardwareID, gotDPInfo []byte) (macPackageDecrypter, error) { - Expect(ctx).ToNot(BeNil()) - Expect(hardwareID).To(Equal([]byte{0x00, 0x11, 0x22, 0xaa, 0xbb, 0xcc})) - Expect(gotDPInfo).To(Equal(dpInfo)) - - return decrypter, nil - }, - } - out, err := store.Download(DownloadInput{ - Context: context.Background(), - App: App{ID: 42, BundleID: "com.example.mac"}, - OutputPath: requestedPath, - Platform: PlatformMacOS, - ExternalVersionID: "123456789", - }) ->>>>>>> c66d653 (fix: select macos packages for universal apps) err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) Expect(err).ToNot(HaveOccurred()) @@ -821,106 +708,9 @@ var _ = Describe("AppStore (Download)", func() { path := writePackage([]string{"iPhoneOS"}) defer os.Remove(path) -<<<<<<< HEAD err := (&appstore{}).validatePackagePlatform(path, PlatformAppleTV) Expect(err).To(HaveOccurred()) Expect(err.Error()).To(ContainSubstring("AppleTVOS")) -======= - mockMachine.EXPECT(). - MacAddress(). - Return("00:11:22:aa:bb:cc", nil) - mockDownloadClient.EXPECT(). - Send(gomock.Any()). - Return(http.Result[downloadResult]{ - StatusCode: 200, - Data: downloadResult{ - Items: []downloadItemResult{{ - URL: "https://example.test/developer.ipa", - Sinfs: []Sinf{{ - ID: 0, - Data: []byte("mobile sinf"), - }}, - Metadata: map[string]interface{}{ - "bundleShortVersionString": "11.0.2", - "software-platform": "ios", - "product-type": "ios-app", - }, - }}, - }, - }, nil) - mockHTTPClient.EXPECT(). - NewRequest("GET", "https://example.test/developer.ipa", nil). - Return(&gohttp.Request{Header: gohttp.Header{}}, nil) - mockHTTPClient.EXPECT(). - Do(gomock.Any()). - Return(&gohttp.Response{ - StatusCode: gohttp.StatusOK, - Body: io.NopCloser(bytes.NewReader(packageBuffer.Bytes())), - ContentLength: int64(packageBuffer.Len()), - }, nil) - - store := &appstore{ - downloadClient: mockDownloadClient, - httpClient: mockHTTPClient, - machine: mockMachine, - os: operatingsystem.New(), - macDecrypterFactory: func(context.Context, []byte, []byte) (macPackageDecrypter, error) { - Fail("mobile packages must not initialize the macOS package decrypter") - - return nil, nil - }, - } - - out, err := store.Download(DownloadInput{ - Context: context.Background(), - Account: Account{Email: "test@example.com"}, - App: App{ID: 640199958, BundleID: "developer.apple.wwdc-Release"}, - OutputPath: tempDir, - Platform: PlatformMacOS, - ExternalVersionID: "123456789", - }) - - Expect(err).ToNot(HaveOccurred()) - Expect(out.DestinationPath).To(Equal(requestedPath)) - Expect(out.Sinfs).To(Equal([]Sinf{{ID: 0, Data: []byte("mobile sinf")}})) - Expect(requestedPath).To(BeAnExistingFile()) - Expect(requestedPath + macEncryptedStageSuffix).ToNot(BeAnExistingFile()) - Expect(requestedPath + macDecryptedStageSuffix).ToNot(BeAnExistingFile()) - }) - - It("uses a pkg name derived from the generated package name", func() { - store := &appstore{os: operatingsystem.New()} - tempDir := GinkgoT().TempDir() - - packagePath, err := store.resolveDestinationPath( - App{ID: 42, BundleID: "com.example.mac"}, - "1.2.3", - tempDir, - PlatformMacOS, - ) - Expect(err).ToNot(HaveOccurred()) - Expect(packagePath).To(Equal(filepath.Join(tempDir, "com.example.mac_42_1.2.3.pkg"))) - }) - - It("preserves an explicit output path", func() { - store := &appstore{os: operatingsystem.New()} - tempDir := GinkgoT().TempDir() - requestedPath := filepath.Join(tempDir, "custom-output") - - packagePath, err := store.resolveDestinationPath(App{}, "1.2.3", requestedPath, PlatformMacOS) - Expect(err).ToNot(HaveOccurred()) - Expect(packagePath).To(Equal(requestedPath)) - }) - - It("rejects a download response without dpInfo", func() { - _, err := macDPInfo(nil) - Expect(err).To(MatchError(ContainSubstring("dpInfo"))) - }) - - It("rejects conflicting dpInfo values", func() { - _, err := macDPInfo([]Sinf{{DPInfo: []byte("one")}, {DPInfo: []byte("two")}}) - Expect(err).To(MatchError(ContainSubstring("conflicting"))) ->>>>>>> c66d653 (fix: select macos packages for universal apps) }) }) diff --git a/resources/ios-entitlements.plist b/resources/ios-entitlements.plist new file mode 100644 index 0000000..441964d --- /dev/null +++ b/resources/ios-entitlements.plist @@ -0,0 +1,14 @@ + + + + + application-identifier + dev.majd.ipatool + com.apple.private.security.no-container + + keychain-access-groups + + dev.majd.ipatool + + + diff --git a/tools/build-ios.sh b/tools/build-ios.sh new file mode 100755 index 0000000..cd833bf --- /dev/null +++ b/tools/build-ios.sh @@ -0,0 +1,42 @@ +#!/bin/sh +set -eu + +# Build a standalone executable for jailbroken arm64 devices running iOS 15+. +ROOT=$(CDPATH='' cd -- "$(dirname -- "$0")/.." && pwd) +OUTPUT=${1:-ipatool-ios-arm64} +VERSION=${VERSION:-dev} + +for TOOL in ldid cmake; do + command -v "$TOOL" >/dev/null 2>&1 || { + echo "$TOOL is required; install it with 'brew install ldid cmake'." >&2 + exit 1 + } +done + +SDKROOT=$(xcrun --sdk iphoneos --show-sdk-path) +CC=$(xcrun --sdk iphoneos --find clang) +export SDKROOT CC +export IPHONEOS_DEPLOYMENT_TARGET=15.0 +export GOOS=ios GOARCH=arm64 CGO_ENABLED=1 + +BUILD_DIR=$(mktemp -d /tmp/ipatool-ios.XXXXXX) +trap 'rm -rf "$BUILD_DIR"' EXIT HUP INT TERM + +# Keep this version in sync with internal/sap/unicorn/artifact.go. +curl -fL --retry 3 https://github.com/unicorn-engine/unicorn/archive/refs/tags/2.1.4.tar.gz -o "$BUILD_DIR/unicorn.tar.gz" +echo "ea8863f095a0136388694e5a6063afd9bb7650e30243dd6251af59c5ce5601f4 $BUILD_DIR/unicorn.tar.gz" | shasum -a 256 -c - +tar -xzf "$BUILD_DIR/unicorn.tar.gz" -C "$BUILD_DIR" +# Use iOS W^X memory protection for Unicorn, including nested Go callbacks. +patch -d "$BUILD_DIR/unicorn-2.1.4" -p1 < "$ROOT/tools/patches/unicorn-ios.patch" +ARCHFLAGS='-arch arm64' cmake -S "$BUILD_DIR/unicorn-2.1.4" -B "$BUILD_DIR/build" \ + -DCMAKE_SYSTEM_NAME=iOS -DCMAKE_OSX_SYSROOT="$SDKROOT" \ + -DCMAKE_OSX_ARCHITECTURES=arm64 -DCMAKE_OSX_DEPLOYMENT_TARGET=15.0 \ + -DCMAKE_C_FLAGS='-target arm64-apple-ios15.0' -DCMAKE_BUILD_TYPE=Release \ + -DBUILD_SHARED_LIBS=OFF -DUNICORN_ARCH=x86 -DUNICORN_BUILD_TESTS=OFF -DUNICORN_INSTALL=OFF +cmake --build "$BUILD_DIR/build" --parallel "$(sysctl -n hw.ncpu)" + +# purego resolves the statically linked Unicorn API through dlsym. +export CGO_LDFLAGS="${CGO_LDFLAGS:-} -Wl,-force_load,$BUILD_DIR/build/libunicorn.a -Wl,-export_dynamic" +cd "$ROOT" +go build -ldflags="-X github.com/majd/ipatool/v2/cmd.version=$VERSION" -o "$OUTPUT" . +ldid -S"$ROOT/resources/ios-entitlements.plist" "$OUTPUT" diff --git a/tools/patches/unicorn-ios.patch b/tools/patches/unicorn-ios.patch new file mode 100644 index 0000000..5f83473 --- /dev/null +++ b/tools/patches/unicorn-ios.patch @@ -0,0 +1,76 @@ +--- a/qemu/configure ++++ b/qemu/configure +@@ -2152,7 +2152,7 @@ + if [ "$darwin" = "yes" ] ; then + cat > $TMPC << EOF + #include +-int main() { pthread_jit_write_protect_supported_np(); return 0;} ++int main() { pthread_jit_write_protect_supported_np(); pthread_jit_write_protect_np(0); return 0;} + EOF + if ! compile_prog ""; then + have_pthread_jit_protect='no' +--- a/qemu/include/tcg/tcg-apple-jit.h ++++ b/qemu/include/tcg/tcg-apple-jit.h +@@ -104,13 +104,11 @@ + #endif + + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__)) ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + +-/* write protect enable = write disable */ +-static inline void jit_write_protect(int enabled) +-{ +- return pthread_jit_write_protect_np(enabled); +-} ++struct uc_struct; ++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled); ++#define jit_write_protect(enabled) ipatool_jit_write_protect(uc, enabled) + + #define JIT_CALLBACK_GUARD(x) \ + { \ +--- a/qemu/accel/tcg/translate-all.c ++++ b/qemu/accel/tcg/translate-all.c +@@ -1019,7 +1019,7 @@ + static inline void *alloc_code_gen_buffer(struct uc_struct *uc) + { + TCGContext *tcg_ctx = uc->tcg_ctx; +- int prot = PROT_WRITE | PROT_READ | PROT_EXEC; ++ int prot = PROT_WRITE | PROT_READ; + int flags = MAP_PRIVATE | MAP_ANONYMOUS; + size_t size = tcg_ctx->code_gen_buffer_size; + void *buf; +@@ -2172,7 +2172,20 @@ + } + + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && (defined(__arm__) || defined(__aarch64__)) ++/* iOS CLI processes cannot use MAP_JIT outside an app sandbox. Toggle the ++ * translation buffer between writable and executable instead of using RWX. */ ++void ipatool_jit_write_protect(struct uc_struct *uc, int enabled) ++{ ++ TCGContext *ctx = uc->tcg_ctx; ++ if (ctx && ctx->initial_buffer && ++ mprotect(ctx->initial_buffer, ctx->initial_buffer_size, ++ PROT_READ | (enabled ? PROT_EXEC : PROT_WRITE)) != 0) { ++ perror("protect Unicorn translation buffer"); ++ abort(); ++ } ++} ++ ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + static bool tb_exec_is_locked(struct uc_struct *uc) + { + return uc->current_executable; +--- a/uc.c ++++ b/uc.c +@@ -35,8 +35,7 @@ + static uc_err uc_snapshot(uc_engine *uc); + static uc_err uc_restore_latest_snapshot(uc_engine *uc); + +-#if defined(__APPLE__) && defined(HAVE_PTHREAD_JIT_PROTECT) && \ +- (defined(__arm__) || defined(__aarch64__)) ++#if defined(__ENVIRONMENT_IPHONE_OS_VERSION_MIN_REQUIRED__) + static void save_jit_state(uc_engine *uc) + { + if (!uc->nested) { From 40d7a5c40147d1022d4ddd521e7c93ca29a1db05 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 23:20:40 +0200 Subject: [PATCH 10/18] fix: keep authentication input inline and mask passwords Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cmd/auth.go | 25 ++---------- cmd/prompt.go | 98 ++++++++++++++++++++++++++++++++++++++++++++++ cmd/prompt_test.go | 48 +++++++++++++++++++++++ 3 files changed, 149 insertions(+), 22 deletions(-) create mode 100644 cmd/prompt.go create mode 100644 cmd/prompt_test.go diff --git a/cmd/auth.go b/cmd/auth.go index 7f3582d..f2bfdb2 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -1,19 +1,16 @@ package cmd import ( - "bufio" "encoding/json" "errors" "fmt" "io" "os" - "strings" "time" "github.com/avast/retry-go" "github.com/majd/ipatool/v2/pkg/appstore" "github.com/spf13/cobra" - "golang.org/x/term" ) func authCmd() *cobra.Command { @@ -32,18 +29,6 @@ func authCmd() *cobra.Command { } func loginCmd() *cobra.Command { - promptForAuthCode := func() (string, error) { - authCode, err := bufio.NewReader(os.Stdin).ReadString('\n') - if err != nil { - return "", fmt.Errorf("failed to read string: %w", err) - } - - authCode = strings.Trim(authCode, "\n") - authCode = strings.Trim(authCode, "\r") - - return authCode, nil - } - var email, password, authCode, sessionOutput string var mzfinance bool @@ -58,13 +43,11 @@ func loginCmd() *cobra.Command { } if password == "" && interactive { - dependencies.Logger.Log().Msg("enter password:") - - bytes, err := term.ReadPassword(int(os.Stdin.Fd())) + value, err := readPrompt("enter password: ", true) if err != nil { return fmt.Errorf("failed to read password: %w", err) } - password = string(bytes) + password = value } var lastErr error @@ -72,10 +55,8 @@ func loginCmd() *cobra.Command { // nolint:wrapcheck return retry.Do(func() error { if errors.Is(lastErr, appstore.ErrAuthCodeRequired) && interactive { - dependencies.Logger.Log().Msg("enter 2FA code:") - var err error - authCode, err = promptForAuthCode() + authCode, err = readPrompt("enter 2FA code: ", false) if err != nil { return fmt.Errorf("failed to read auth code: %w", err) } diff --git a/cmd/prompt.go b/cmd/prompt.go new file mode 100644 index 0000000..322d8c8 --- /dev/null +++ b/cmd/prompt.go @@ -0,0 +1,98 @@ +package cmd + +import ( + "bufio" + "errors" + "fmt" + "io" + "os" + "strings" + "unicode" + + "golang.org/x/term" +) + +func readPrompt(prompt string, masked bool) (string, error) { + if masked { + state, err := term.MakeRaw(int(os.Stdin.Fd())) + if err != nil { + return "", fmt.Errorf("failed to configure terminal: %w", err) + } + defer term.Restore(int(os.Stdin.Fd()), state) //nolint:errcheck + } + + // Prompts belong on stderr so structured output on stdout stays valid. + if _, err := io.WriteString(os.Stderr, prompt); err != nil { + return "", fmt.Errorf("failed to write prompt: %w", err) + } + + if masked { + defer fmt.Fprint(os.Stderr, "\r\n") + + return readMaskedInput(os.Stdin, os.Stderr) + } + + line, err := bufio.NewReader(os.Stdin).ReadString('\n') + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + return strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r"), nil +} + +func readMaskedInput(input io.Reader, output io.Writer) (string, error) { + reader := bufio.NewReader(input) + + var ( + password []rune + escape int + ) + + for { + key, _, err := reader.ReadRune() + if err != nil { + return "", fmt.Errorf("failed to read input: %w", err) + } + + // Ignore terminal escape sequences (for example, arrow keys). + if escape != 0 { + if escape == 1 && (key == '[' || key == 'O') { + escape = 2 + } else if escape == 1 || (key >= 0x40 && key <= 0x7e) { + escape = 0 + } + + continue + } + + var echo string + + switch key { + case '\r', '\n': + return string(password), nil + case 3: + return "", errors.New("input interrupted") + case 4: + return "", io.EOF + case 27: + escape = 1 + case '\b', 127: + if len(password) > 0 { + password = password[:len(password)-1] + echo = "\b \b" + } + case 21: + echo = strings.Repeat("\b \b", len(password)) + password = password[:0] + default: + if unicode.IsPrint(key) { + password = append(password, key) + echo = "*" + } + } + + if _, err := io.WriteString(output, echo); err != nil { + return "", fmt.Errorf("failed to echo input: %w", err) + } + } +} diff --git a/cmd/prompt_test.go b/cmd/prompt_test.go new file mode 100644 index 0000000..bf2a208 --- /dev/null +++ b/cmd/prompt_test.go @@ -0,0 +1,48 @@ +package cmd + +import ( + "bytes" + "io" + "strings" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Masked input", func() { + DescribeTable("echoes masks while preserving the entered password", func(input, password, echo string) { + var output bytes.Buffer + value, err := readMaskedInput(strings.NewReader(input), &output) + + Expect(err).NotTo(HaveOccurred()) + Expect(value).To(Equal(password)) + Expect(output.String()).To(Equal(echo)) + }, + Entry("enter", "secret\r", "secret", "******"), + Entry("newline", "secret\n", "secret", "******"), + Entry("unicode", "pä密🔑\r", "pä密🔑", "****"), + Entry("delete", "ab\x7fc\r", "ac", "**\b \b*"), + Entry("unicode backspace", "a密\bc\r", "ac", "**\b \b*"), + Entry("backspace on empty input", "\b\x7fa\r", "a", "*"), + Entry("clear line", "ab\x15c\r", "c", "**\b \b\b \b*"), + Entry("arrow keys", "ab\x1b[D\x1bOCc\r", "abc", "***"), + Entry("empty input", "\r", "", ""), + ) + + It("cancels without returning the password on Ctrl-C", func() { + value, err := readMaskedInput(strings.NewReader("secret\x03"), io.Discard) + + Expect(err).To(MatchError("input interrupted")) + Expect(value).To(BeEmpty()) + }) + + DescribeTable("discards incomplete input", func(input string) { + value, err := readMaskedInput(strings.NewReader(input), io.Discard) + + Expect(err).To(MatchError(ContainSubstring("EOF"))) + Expect(value).To(BeEmpty()) + }, + Entry("end of stream", "secret"), + Entry("Ctrl-D", "secret\x04"), + ) +}) From 2c1955fbb069d99c277037a0d0179d420896f330 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sun, 13 Sep 2026 23:28:20 +0200 Subject: [PATCH 11/18] feat: prompt for email during interactive login --- cmd/auth.go | 14 ++++++- cmd/auth_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 cmd/auth_test.go diff --git a/cmd/auth.go b/cmd/auth.go index f2bfdb2..4c022ba 100644 --- a/cmd/auth.go +++ b/cmd/auth.go @@ -38,6 +38,18 @@ func loginCmd() *cobra.Command { RunE: func(cmd *cobra.Command, args []string) error { interactive := cmd.Context().Value(interactiveKey).(bool) + if email == "" && !interactive { + return errors.New("email is required when not running in interactive mode; use the \"--email\" flag") + } + + if email == "" && interactive { + value, err := readPrompt("enter email: ", false) + if err != nil { + return fmt.Errorf("failed to read email: %w", err) + } + email = value + } + if password == "" && !interactive { return errors.New("password is required when not running in interactive mode; use the \"--password\" flag") } @@ -138,8 +150,6 @@ func loginCmd() *cobra.Command { cmd.Flags().StringVar(&sessionOutput, "session-output", "", "path to save the account session to after a successful login") cmd.Flags().BoolVar(&mzfinance, "mzfinance", false, "use the stable legacy MZFinance login flow (GSA -> MZFinance) instead of the default native/fast path") - _ = cmd.MarkFlagRequired("email") - return cmd } diff --git a/cmd/auth_test.go b/cmd/auth_test.go new file mode 100644 index 0000000..b3fbbf0 --- /dev/null +++ b/cmd/auth_test.go @@ -0,0 +1,101 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Login command", func() { + var store *fakeLoginAppStore + + BeforeEach(func() { + previousDependencies := dependencies + DeferCleanup(func() { dependencies = previousDependencies }) + store = &fakeLoginAppStore{} + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + }) + + DescribeTable("requires credentials in non-interactive mode", func(args []string, message string) { + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false)) + cmd.SetArgs(args) + + Expect(cmd.Execute()).To(MatchError(message)) + Expect(store.loginCalls).To(BeZero()) + }, + Entry("missing email", []string{"--password", "secret"}, "email is required when not running in interactive mode; use the \"--email\" flag"), + Entry("missing password", []string{"--email", "user@example.com"}, "password is required when not running in interactive mode; use the \"--password\" flag"), + ) + + DescribeTable("uses supplied credentials", func(interactive bool) { + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, interactive)) + cmd.SetArgs([]string{"--email", "user@example.com", "--password", "secret"}) + + Expect(cmd.Execute()).To(Succeed()) + Expect(store.loginCalls).To(Equal(1)) + Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + }, + Entry("interactive", true), + Entry("non-interactive", false), + ) + + DescribeTable("prompts for email", func(input, message string) { + dir := GinkgoT().TempDir() + inputPath := filepath.Join(dir, "stdin") + Expect(os.WriteFile(inputPath, []byte(input), 0o600)).To(Succeed()) + stdin, err := os.Open(inputPath) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stdin.Close) + stderr, err := os.Create(filepath.Join(dir, "stderr")) + Expect(err).NotTo(HaveOccurred()) + DeferCleanup(stderr.Close) + + previousStdin, previousStderr := os.Stdin, os.Stderr + os.Stdin, os.Stderr = stdin, stderr + DeferCleanup(func() { os.Stdin, os.Stderr = previousStdin, previousStderr }) + + cmd := loginCmd() + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, true)) + cmd.SetArgs([]string{"--password", "secret"}) + cmd.SilenceErrors = true + cmd.SilenceUsage = true + + err = cmd.Execute() + if message == "" { + Expect(err).NotTo(HaveOccurred()) + Expect(store.loginCalls).To(Equal(1)) + Expect(store.input).To(Equal(appstore.LoginInput{Email: "user@example.com", Password: "secret"})) + } else { + Expect(err).To(MatchError(message)) + Expect(store.loginCalls).To(BeZero()) + } + + prompt, err := os.ReadFile(stderr.Name()) + Expect(err).NotTo(HaveOccurred()) + Expect(string(prompt)).To(Equal("enter email: ")) + }, + Entry("reads unmasked input without requiring a terminal", "user@example.com\n", ""), + Entry("reports input errors", "", "failed to read email: failed to read input: EOF"), + ) +}) + +type fakeLoginAppStore struct { + appstore.AppStore + input appstore.LoginInput + loginCalls int +} + +func (f *fakeLoginAppStore) Login(input appstore.LoginInput) (appstore.LoginOutput, error) { + f.input = input + f.loginCalls++ + + return appstore.LoginOutput{}, nil +} From 13bf043521d611fbcd8a5e22f241963d4fd39260 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Fri, 18 Sep 2026 23:10:34 +0200 Subject: [PATCH 12/18] fix: fall back to consumer catalogs for ios version lookup Cherry-pick of upstream e5211d6 (adapted): - lookupLatestExternalVersionID now tries the enterprise catalog first and falls back to the iphone/ipad consumer catalogs for iOS platforms - platformVersionLookupRequest takes the catalog string directly - visionOS lookup split out to the storefront product page; ported visionProductURL/visionExternalVersionID helpers into storefront.go - skipped appstore_download_product_test.go changes (file does not exist in this fork) - added upstream appstore_platform_version_lookup_test.go Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- .../appstore_platform_version_lookup.go | 98 +++++++++++----- .../appstore_platform_version_lookup_test.go | 99 ++++++++++++++++ pkg/appstore/storefront.go | 110 ++++++++++++++++++ 3 files changed, 279 insertions(+), 28 deletions(-) create mode 100644 pkg/appstore/appstore_platform_version_lookup_test.go diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go index 3334fe9..3631bd3 100644 --- a/pkg/appstore/appstore_platform_version_lookup.go +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -66,52 +66,94 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform return "", fmt.Errorf("failed to resolve the country code: %w", err) } - request, err := t.platformVersionLookupRequest(app.ID, countryCode, platform) - if err != nil { - return "", fmt.Errorf("failed to create platform version lookup request: %w", err) + if platform == PlatformVisionOS { + return t.lookupLatestVisionOSExternalVersionID(app.ID, countryCode) } - res, err := t.platformClient.Send(request) + metadataPlatform, err := platform.metadataPlatform() if err != nil { - return "", fmt.Errorf("platform version lookup request failed: %w", err) - } - - if res.StatusCode != gohttp.StatusOK { - return "", NewErrorWithMetadata(errors.New("platform version lookup request failed"), res) + return "", fmt.Errorf("failed to create platform version lookup request: %w", err) } - item, ok := res.Data.Results[strconv.FormatInt(app.ID, 10)] - if !ok { - return "", NewErrorWithMetadata(errors.New("platform version lookup returned no app"), res) + catalogs := []string{metadataPlatform} + if platform == PlatformIPhone || platform == PlatformIPad { + // Some storefronts have no enterprise listing even when the consumer + // catalogs contain the app. Keep the account's country for each lookup. + catalogs = append(catalogs, "iphone", "ipad") } - if len(item.Offers) == 0 { - return "", NewErrorWithMetadata(errors.New("platform version lookup returned no offers"), res) - } + var lastErr error - offer := item.Offers[0] - externalVersionID := string(offer.Version.ExternalID) + for _, catalog := range catalogs { + request := t.platformVersionLookupRequest(app.ID, countryCode, catalog) - if externalVersionID == "" { - externalVersionID, err = externalVersionIDFromBuyParams(offer.BuyParams) + res, err := t.platformClient.Send(request) if err != nil { - return "", fmt.Errorf("failed to parse buy params: %w", err) + return "", fmt.Errorf("platform version lookup request failed: %w", err) } - } - if externalVersionID == "" { - return "", NewErrorWithMetadata(errors.New("platform version lookup returned no external version id"), res) + if res.StatusCode != gohttp.StatusOK { + return "", NewErrorWithMetadata(errors.New("platform version lookup request failed"), res) + } + + item, ok := res.Data.Results[strconv.FormatInt(app.ID, 10)] + if !ok { + lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no app"), res) + + continue + } + + if len(item.Offers) == 0 { + lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no offers"), res) + + continue + } + + offer := item.Offers[0] + externalVersionID := string(offer.Version.ExternalID) + + if externalVersionID == "" { + externalVersionID, err = externalVersionIDFromBuyParams(offer.BuyParams) + if err != nil { + return "", fmt.Errorf("failed to parse buy params: %w", err) + } + } + + if externalVersionID == "" { + return "", NewErrorWithMetadata(errors.New("platform version lookup returned no external version id"), res) + } + + return externalVersionID, nil } - return externalVersionID, nil + return "", fmt.Errorf("app %d in storefront %s (catalogs: %s): %w", app.ID, countryCode, strings.Join(catalogs, ", "), lastErr) } -func (*appstore) platformVersionLookupRequest(appID int64, countryCode string, platform Platform) (http.Request, error) { - metadataPlatform, err := platform.metadataPlatform() +func (t *appstore) lookupLatestVisionOSExternalVersionID(appID int64, countryCode string) (string, error) { + request := http.Request{ + URL: visionProductURL(appID, countryCode), + Method: http.MethodGET, + ResponseFormat: http.ResponseFormatRaw, + } + + res, err := t.storefrontClient.Send(request) + if err != nil { + return "", fmt.Errorf("visionOS version lookup request failed: %w", err) + } + + if res.StatusCode != gohttp.StatusOK { + return "", NewErrorWithMetadata(errors.New("visionOS version lookup request failed"), res) + } + + externalVersionID, err := visionExternalVersionID(res.Data, appID) if err != nil { - return http.Request{}, err + return "", fmt.Errorf("failed to parse visionOS version lookup response: %w", err) } + return externalVersionID, nil +} + +func (*appstore) platformVersionLookupRequest(appID int64, countryCode, metadataPlatform string) http.Request { params := url.Values{} params.Add("version", "2") params.Add("id", strconv.FormatInt(appID, 10)) @@ -125,7 +167,7 @@ func (*appstore) platformVersionLookupRequest(appID int64, countryCode string, p URL: fmt.Sprintf("https://uclient-api.itunes.apple.com/WebObjects/MZStorePlatform.woa/wa/lookup?%s", params.Encode()), Method: http.MethodGET, ResponseFormat: http.ResponseFormatJSON, - }, nil + } } func externalVersionIDFromBuyParams(buyParams string) (string, error) { diff --git a/pkg/appstore/appstore_platform_version_lookup_test.go b/pkg/appstore/appstore_platform_version_lookup_test.go new file mode 100644 index 0000000..9ad18e3 --- /dev/null +++ b/pkg/appstore/appstore_platform_version_lookup_test.go @@ -0,0 +1,99 @@ +package appstore + +import ( + "errors" + "net/url" + + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("Platform version catalog fallback", func() { + var client *http.MockClient[platformVersionLookupResult] + var store *appstore + account := Account{StoreFront: "143469-1,34"} + app := App{ID: 6472431552} + missing := platformVersionLookupResult{} + noOffers := platformVersionLookupResult{Results: map[string]platformVersionLookupItem{"6472431552": {}}} + withOffer := func(offer platformVersionLookupOffer) platformVersionLookupResult { + return platformVersionLookupResult{Results: map[string]platformVersionLookupItem{"6472431552": {Offers: []platformVersionLookupOffer{offer}}}} + } + valid := withOffer(platformVersionLookupOffer{Version: platformVersionLookupVersion{ExternalID: "891116578"}}) + + BeforeEach(func() { + client = http.NewMockClient[platformVersionLookupResult](gomock.NewController(GinkgoT())) + store = &appstore{platformClient: client} + }) + + expectLookup := func(catalog string, status int, data platformVersionLookupResult, sendErr error) *gomock.Call { + return client.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + u, err := url.Parse(req.URL) + Expect(err).ToNot(HaveOccurred()) + Expect(u.Query().Get("platform")).To(Equal(catalog)) + Expect(u.Query().Get("cc")).To(Equal("ru")) + Expect(u.Query().Get("id")).To(Equal("6472431552")) + }).Return(http.Result[platformVersionLookupResult]{StatusCode: status, Data: data}, sendErr) + } + + DescribeTable("stops at the first available iOS catalog", func(platform Platform, empty platformVersionLookupResult, successIndex int, offer platformVersionLookupResult) { + var calls []any + for i, catalog := range []string{"enterprisestore", "iphone", "ipad"} { + data := empty + if i == successIndex { + data = offer + } + calls = append(calls, expectLookup(catalog, 200, data, nil)) + if i == successIndex { + break + } + } + gomock.InOrder(calls...) + version, err := store.lookupLatestExternalVersionID(account, app, platform) + Expect(err).ToNot(HaveOccurred()) + Expect(version).To(Equal("891116578")) + }, + Entry("enterprise succeeds", PlatformIPhone, missing, 0, valid), + Entry("missing enterprise app", PlatformIPhone, missing, 1, valid), + Entry("empty enterprise offers", PlatformIPhone, noOffers, 1, valid), + Entry("only iPad catalog succeeds", PlatformIPhone, missing, 2, valid), + Entry("empty offers until iPad", PlatformIPad, noOffers, 2, valid), + Entry("iPad request uses iPhone fallback first", PlatformIPad, missing, 1, valid), + Entry("consumer buy params version", PlatformIPhone, missing, 1, withOffer(platformVersionLookupOffer{BuyParams: "appExtVrsId=891116578"})), + ) + + DescribeTable("reports exhausted catalogs", func(empty platformVersionLookupResult, message string) { + gomock.InOrder(expectLookup("enterprisestore", 200, empty, nil), expectLookup("iphone", 200, empty, nil), expectLookup("ipad", 200, empty, nil)) + version, err := store.lookupLatestExternalVersionID(account, app, PlatformIPhone) + Expect(version).To(BeEmpty()) + Expect(err).To(MatchError(And(ContainSubstring(message), ContainSubstring("6472431552"), ContainSubstring("RU"), ContainSubstring("enterprisestore, iphone, ipad")))) + }, Entry("missing apps", missing, "returned no app"), Entry("empty offers", noOffers, "returned no offers")) + + DescribeTable("does not fall back after a request or offer error", func(status int, data platformVersionLookupResult, sendErr error, message string) { + expectLookup("enterprisestore", status, data, sendErr) + _, err := store.lookupLatestExternalVersionID(account, app, PlatformIPhone) + Expect(err).To(MatchError(ContainSubstring(message))) + if sendErr != nil { + Expect(errors.Is(err, sendErr)).To(BeTrue()) + } + }, + Entry("transport failure", 0, missing, errors.New("connection reset"), "request failed"), + Entry("decoding failure", 200, missing, errors.New("invalid JSON"), "request failed"), + Entry("HTTP failure", 503, missing, nil, "request failed"), + Entry("missing version", 200, withOffer(platformVersionLookupOffer{}), nil, "no external version id"), + Entry("malformed buy params", 200, withOffer(platformVersionLookupOffer{BuyParams: "appExtVrsId=%zz"}), nil, "failed to parse buy params"), + ) + + It("stops on an HTTP error in a consumer catalog", func() { + gomock.InOrder(expectLookup("enterprisestore", 200, missing, nil), expectLookup("iphone", 503, missing, nil)) + _, err := store.lookupLatestExternalVersionID(account, app, PlatformIPhone) + Expect(err).To(MatchError(ContainSubstring("request failed"))) + }) + + It("does not use iOS catalogs for Apple TV", func() { + expectLookup("atv9", 200, missing, nil) + _, err := store.lookupLatestExternalVersionID(account, app, PlatformAppleTV) + Expect(err).To(MatchError(ContainSubstring("returned no app"))) + }) +}) diff --git a/pkg/appstore/storefront.go b/pkg/appstore/storefront.go index 8f0f7a9..7b8b553 100644 --- a/pkg/appstore/storefront.go +++ b/pkg/appstore/storefront.go @@ -1,7 +1,11 @@ package appstore import ( + "encoding/json" + "errors" "fmt" + "net/url" + "strconv" "strings" ) @@ -153,3 +157,109 @@ var storeFronts = map[string]string{ "YE": "143571", "ZA": "143472", } + +func visionProductURL(appID int64, countryCode string) string { + params := url.Values{} + params.Set("platform", "vision") + + return fmt.Sprintf("https://apps.apple.com/%s/app/id%d?%s", strings.ToLower(countryCode), appID, params.Encode()) +} + +func visionExternalVersionID(body []byte, appID int64) (string, error) { + data, err := serializedServerData(body) + if err != nil { + return "", err + } + + var value interface{} + if err := json.Unmarshal(data, &value); err != nil { + return "", fmt.Errorf("failed to decode serialized server data: %w", err) + } + + externalVersionID, found := findVisionExternalVersionID(value, appID) + if !found { + return "", errors.New("visionOS purchase configuration was not found") + } + + if externalVersionID == "" { + return "", errors.New("visionOS purchase configuration has no external version id") + } + + return externalVersionID, nil +} + +func findVisionExternalVersionID(value interface{}, appID int64) (string, bool) { + matched := false + + switch typedValue := value.(type) { + case []interface{}: + for _, item := range typedValue { + if externalVersionID, found := findVisionExternalVersionID(item, appID); found { + if externalVersionID != "" { + return externalVersionID, true + } + + matched = true + } + } + case map[string]interface{}: + if configuration, ok := typedValue["purchaseConfiguration"].(map[string]interface{}); ok { + if externalVersionID, found := externalVersionIDFromVisionConfiguration(configuration, appID); found { + if externalVersionID != "" { + return externalVersionID, true + } + + matched = true + } + } + + for _, child := range typedValue { + if externalVersionID, found := findVisionExternalVersionID(child, appID); found { + if externalVersionID != "" { + return externalVersionID, true + } + + matched = true + } + } + } + + return "", matched +} + +func externalVersionIDFromVisionConfiguration(configuration map[string]interface{}, appID int64) (string, bool) { + if configuration["metricsPlatformDisplayStyle"] != "vision" { + return "", false + } + + platforms, ok := configuration["appPlatforms"].([]interface{}) + if !ok { + return "", false + } + + isVisionApp := false + + for _, platform := range platforms { + if platform == "vision" { + isVisionApp = true + + break + } + } + + if !isVisionApp { + return "", false + } + + buyParams, ok := configuration["buyParams"].(string) + if !ok || buyParams == "" { + return "", false + } + + values, err := url.ParseQuery(buyParams) + if err != nil || values.Get("salableAdamId") != strconv.FormatInt(appID, 10) { + return "", false + } + + return values.Get("appExtVrsId"), true +} From 7b2a34dadf9ca5326f4278e49b80bfb31dd14774 Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Sat, 19 Sep 2026 16:43:49 +0200 Subject: [PATCH 13/18] fix: normalize 2fa input and clarify verification errors Cherry-pick of upstream 735b689 (adapted to the fork layout): - normalizeAuthCode strips whitespace, bracketed-paste markers and validates the six-digit format; applied in both Login entry points (Login and LoginMZFinance) so every downstream path (legacy, GSA) receives the normalized code - parseLoginResponse now distinguishes a missing 2FA code from a failed verification with a dedicated error message - dropped the incoming parseLoginResponse/loginRequest duplicates (upstream's SAP-signer variants) in favor of the fork's versions - adopted all four test additions (malformed-code table, password normalization table, incomplete-verification case) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_login.go | 47 +++++++++++++++++++++++++++-- pkg/appstore/appstore_login_test.go | 43 ++++++++++++++++++++++++++ 2 files changed, 88 insertions(+), 2 deletions(-) diff --git a/pkg/appstore/appstore_login.go b/pkg/appstore/appstore_login.go index ff561a8..9a4d388 100644 --- a/pkg/appstore/appstore_login.go +++ b/pkg/appstore/appstore_login.go @@ -10,6 +10,7 @@ import ( "strconv" "strings" "time" + "unicode" "github.com/majd/ipatool/v2/pkg/gsa" "github.com/majd/ipatool/v2/pkg/http" @@ -40,6 +41,12 @@ type LoginOutput struct { } func (t *appstore) Login(input LoginInput) (LoginOutput, error) { + authCode, err := normalizeAuthCode(input.AuthCode) + if err != nil { + return LoginOutput{}, err + } + input.AuthCode = authCode + macAddr, err := t.machine.MacAddress() if err != nil { return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err) @@ -112,6 +119,12 @@ func (t *appstore) Login(input LoginInput) (LoginOutput, error) { // endpoint (the same stable path used on Windows), bypassing the glitchy // native/fast endpoint that Login may fall back to on macOS. func (t *appstore) LoginMZFinance(input LoginInput) (LoginOutput, error) { + authCode, err := normalizeAuthCode(input.AuthCode) + if err != nil { + return LoginOutput{}, err + } + input.AuthCode = authCode + macAddr, err := t.machine.MacAddress() if err != nil { return LoginOutput{}, fmt.Errorf("failed to get mac address: %w", err) @@ -202,6 +215,32 @@ func (t *appstore) loginWithGSA(input LoginInput, guid string) (Account, error) return out, nil } +func normalizeAuthCode(code string) (string, error) { + if code == "" { + return "", nil + } + + // Terminals may wrap pasted input in bracketed-paste markers. Strip only + // a matched outer pair; other escape sequences are invalid input. + code = strings.TrimSpace(code) + if strings.HasPrefix(code, "\x1b[200~") && strings.HasSuffix(code, "\x1b[201~") { + code = strings.TrimSuffix(strings.TrimPrefix(code, "\x1b[200~"), "\x1b[201~") + } + + code = strings.Map(func(r rune) rune { + if unicode.IsSpace(r) { + return -1 + } + + return r + }, code) + if len(code) != 6 || strings.IndexFunc(code, func(r rune) bool { return r < '0' || r > '9' }) != -1 { + return "", errors.New("2FA code must contain exactly six digits") + } + + return code, nil +} + type loginAddressResult struct { FirstName string `plist:"firstName,omitempty"` LastName string `plist:"lastName,omitempty"` @@ -436,8 +475,12 @@ func (t *appstore) parseLoginResponse(res *http.Result[loginResult], authCode st } else { retry = true } - } else if res.Data.FailureType == "" && authCode == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { - err = ErrAuthCodeRequired + } else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageBadLogin { + if authCode == "" { + err = ErrAuthCodeRequired + } else { + err = errors.New("apple did not complete verification; try a fresh 2FA code") + } } else if res.Data.FailureType == "" && res.Data.CustomerMessage == CustomerMessageAccountDisabled { err = NewErrorWithMetadata(errors.New("account is disabled"), res) } else if res.Data.FailureType != "" { diff --git a/pkg/appstore/appstore_login_test.go b/pkg/appstore/appstore_login_test.go index af9857f..0c5a1bd 100644 --- a/pkg/appstore/appstore_login_test.go +++ b/pkg/appstore/appstore_login_test.go @@ -47,6 +47,21 @@ var _ = Describe("AppStore (Login)", func() { ctrl.Finish() }) + DescribeTable("rejects malformed 2FA codes before preparing authentication", func(code string) { + _, err := as.Login(LoginInput{AuthCode: code}) + + Expect(err).To(MatchError("2FA code must contain exactly six digits")) + }, + Entry("whitespace only", " \t\r\n"), + Entry("too short", "12345"), + Entry("too long", "1234567"), + Entry("letters", "12345a"), + Entry("non-ASCII digits", "123456"), + Entry("other escape sequences", "\x1b[31m123456"), + Entry("unmatched paste marker", "\x1b[200~123456"), + Entry("empty paste", "\x1b[200~\x1b[201~"), + Entry("embedded paste markers", "123\x1b[200~456\x1b[201~"), + ) When("fails to read Machine's MAC address", func() { BeforeEach(func() { mockMachine.EXPECT(). @@ -69,6 +84,26 @@ var _ = Describe("AppStore (Login)", func() { Return("00:00:00:00:00:00", nil) }) + DescribeTable("normalizes 2FA codes without changing the password", func(code, suffix string) { + const password = " \tpäss word\n" + mockClient.EXPECT().Send(gomock.Any()).DoAndReturn(func(req http.Request) (http.Result[loginResult], error) { + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("password", password+suffix)) + + return http.Result[loginResult]{}, errors.New("test complete") + }) + + _, err := as.Login(LoginInput{Password: password, AuthCode: code}) + + Expect(err).To(MatchError(ContainSubstring("test complete"))) + }, + Entry("no code on initial login", "", ""), + Entry("plain code with leading zero", "012345", "012345"), + Entry("spaces", "123 456", "123456"), + Entry("Unicode whitespace", "\t123\u00a0456\r\n", "123456"), + Entry("bracketed paste", "\x1b[200~123456\x1b[201~", "123456"), + Entry("bracketed paste with whitespace", " \x1b[200~123 456\n\x1b[201~\r\n", "123456"), + ) + When("client returns error", func() { BeforeEach(func() { mockClient.EXPECT(). @@ -209,6 +244,7 @@ var _ = Describe("AppStore (Login)", func() { mockClient.EXPECT(). Send(gomock.Any()). Return(http.Result[loginResult]{ + StatusCode: 200, Data: loginResult{ FailureType: "", CustomerMessage: CustomerMessageBadLogin, @@ -222,6 +258,13 @@ var _ = Describe("AppStore (Login)", func() { }) Expect(err).To(Equal(ErrAuthCodeRequired)) }) + + It("reports an incomplete verification when a code was already supplied", func() { + _, err := as.Login(LoginInput{Password: testPassword, AuthCode: "123456"}) + + Expect(err).To(MatchError("apple did not complete verification; try a fresh 2FA code")) + Expect(errors.Is(err, ErrAuthCodeRequired)).To(BeFalse()) + }) }) When("store API redirects", func() { From 7b4739745cd36922a2b16a636b583f830c3cc74d Mon Sep 17 00:00:00 2001 From: Majd Alfhaily Date: Tue, 29 Sep 2026 23:27:52 +0200 Subject: [PATCH 14/18] fix: recover redacted mac addresses on macos 27 Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- go.mod | 1 + go.sum | 2 + pkg/util/machine/mac_address_darwin.go | 127 +++++++++++ pkg/util/machine/mac_address_darwin_test.go | 237 ++++++++++++++++++++ pkg/util/machine/mac_address_default.go | 9 + pkg/util/machine/machine.go | 2 +- pkg/util/machine/machine_test.go | 5 + 7 files changed, 382 insertions(+), 1 deletion(-) create mode 100644 pkg/util/machine/mac_address_darwin.go create mode 100644 pkg/util/machine/mac_address_darwin_test.go create mode 100644 pkg/util/machine/mac_address_default.go diff --git a/go.mod b/go.mod index db2a6c9..c92eb34 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ require ( github.com/avast/retry-go v3.0.0+incompatible github.com/byteness/go-keychain v0.0.0-20191008050251-8e49817e8af4 github.com/byteness/keyring v1.9.0 + github.com/ebitengine/purego v0.10.2 github.com/juju/persistent-cookiejar v1.0.0 github.com/onsi/ginkgo/v2 v2.5.0 github.com/onsi/gomega v1.24.0 diff --git a/go.sum b/go.sum index 987d513..e498ea6 100644 --- a/go.sum +++ b/go.sum @@ -16,6 +16,8 @@ github.com/byteness/percent v0.2.2 h1:vnIFh8WBR1xoC+U2etz0EMB1cgp+vsK6vynqTCeDzi github.com/byteness/percent v0.2.2/go.mod h1:nwavge92FhIyfnldz4YWZD8uxPVvdh8NlzLRd1VYRDs= github.com/coreos/go-systemd/v22 v22.3.3-0.20220203105225-a9a7ef127534/go.mod h1:Y58oyj3AT4RCenI/lSvhwexgC+NSVTIJ3seZv2GcEnc= github.com/cpuguy83/go-md2man/v2 v2.0.2/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/ebitengine/purego v0.10.2 h1:W809HbnvzAxgdm+aOvlSekrM16wGCdT/e76+9tS7gzE= +github.com/ebitengine/purego v0.10.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/danieljoos/wincred v1.2.3 h1:v7dZC2x32Ut3nEfRH+vhoZGvN72+dQ/snVXo/vMFLdQ= github.com/danieljoos/wincred v1.2.3/go.mod h1:6qqX0WNrS4RzPZ1tnroDzq9kY3fu1KwE7MRLQK4X0bs= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= diff --git a/pkg/util/machine/mac_address_darwin.go b/pkg/util/machine/mac_address_darwin.go new file mode 100644 index 0000000..e802add --- /dev/null +++ b/pkg/util/machine/mac_address_darwin.go @@ -0,0 +1,127 @@ +//go:build darwin && !ios + +package machine + +import ( + "bytes" + "errors" + "fmt" + "net" + "unsafe" + + "github.com/ebitengine/purego" +) + +func interfaceMacAddress(networkInterface net.Interface) (string, error) { + return macAddressWithHardwareLookup(networkInterface, hardwareMacAddress) +} + +func macAddressWithHardwareLookup(networkInterface net.Interface, lookup func(string) (net.HardwareAddr, error)) (string, error) { + // macOS 27 can redact network interface addresses with a shared placeholder. + // Resolve the same interface through IOKit to preserve its existing identity. + if !bytes.Equal(networkInterface.HardwareAddr, []byte{0x02, 0, 0, 0, 0, 0}) { + return networkInterface.HardwareAddr.String(), nil + } + + address, err := lookup(networkInterface.Name) + if err != nil { + return "", fmt.Errorf("macOS redacted the mac address for %q; failed to read its hardware address: %w", networkInterface.Name, err) + } + + if len(address) != 6 || address[0]&1 != 0 || + bytes.Equal(address, []byte{0, 0, 0, 0, 0, 0}) || + bytes.Equal(address, []byte{0x02, 0, 0, 0, 0, 0}) { + return "", fmt.Errorf("macOS redacted the mac address for %q; IOKit returned no usable hardware address", networkInterface.Name) + } + + return address.String(), nil +} + +type macAddressAPI struct { + matching func(uint32, uint32, string) uintptr + service func(uint32, uintptr) uint32 + searchProperty func(uint32, string, uintptr, uintptr, uint32) uintptr + releaseObject func(uint32) int32 + createString func(uintptr, string, uint32) uintptr + release func(uintptr) + typeID func(uintptr) uintptr + dataTypeID func() uintptr + dataLength func(uintptr) int64 + dataBytes func(uintptr) unsafe.Pointer +} + +func hardwareMacAddress(name string) (net.HardwareAddr, error) { + iokit, err := purego.Dlopen("/System/Library/Frameworks/IOKit.framework/IOKit", purego.RTLD_NOW|purego.RTLD_LOCAL) + if err != nil { + return nil, fmt.Errorf("load IOKit: %w", err) + } + + defer func() { _ = purego.Dlclose(iokit) }() + + coreFoundation, err := purego.Dlopen("/System/Library/Frameworks/CoreFoundation.framework/CoreFoundation", purego.RTLD_NOW|purego.RTLD_LOCAL) + if err != nil { + return nil, fmt.Errorf("load CoreFoundation: %w", err) + } + + defer func() { _ = purego.Dlclose(coreFoundation) }() + + var api macAddressAPI + + purego.RegisterLibFunc(&api.matching, iokit, "IOBSDNameMatching") + purego.RegisterLibFunc(&api.service, iokit, "IOServiceGetMatchingService") + purego.RegisterLibFunc(&api.searchProperty, iokit, "IORegistryEntrySearchCFProperty") + purego.RegisterLibFunc(&api.releaseObject, iokit, "IOObjectRelease") + purego.RegisterLibFunc(&api.createString, coreFoundation, "CFStringCreateWithCString") + purego.RegisterLibFunc(&api.release, coreFoundation, "CFRelease") + purego.RegisterLibFunc(&api.typeID, coreFoundation, "CFGetTypeID") + purego.RegisterLibFunc(&api.dataTypeID, coreFoundation, "CFDataGetTypeID") + purego.RegisterLibFunc(&api.dataLength, coreFoundation, "CFDataGetLength") + purego.RegisterLibFunc(&api.dataBytes, coreFoundation, "CFDataGetBytePtr") + + return readHardwareMacAddress(name, api) +} + +func readHardwareMacAddress(name string, api macAddressAPI) (net.HardwareAddr, error) { + matching := api.matching(0, 0, name) + if matching == 0 { + return nil, errors.New("could not create interface matching dictionary") + } + + // IOServiceGetMatchingService consumes the matching dictionary. + service := api.service(0, matching) + if service == 0 { + return nil, errors.New("network interface was not found in IOKit") + } + defer api.releaseObject(service) + + const utf8Encoding = 0x08000100 + + key := api.createString(0, "IOMACAddress", utf8Encoding) + if key == 0 { + return nil, errors.New("could not create hardware address property key") + } + defer api.release(key) + + // Apple's receipt-validation guidance reads IOMACAddress from the interface + // or its parents in the IOService plane. + // https://developer.apple.com/documentation/appstorereceipts/validating-receipts-on-the-device + const iterateRecursivelyAndParents = 0x01 | 0x02 + + property := api.searchProperty(service, "IOService", key, 0, iterateRecursivelyAndParents) + if property == 0 { + return nil, errors.New("hardware address property was not found in IOKit") + } + defer api.release(property) + + if api.typeID(property) != api.dataTypeID() || api.dataLength(property) != 6 { + return nil, errors.New("IOKit hardware address must contain six bytes") + } + + data := api.dataBytes(property) + if data == nil { + return nil, errors.New("IOKit hardware address data is missing") + } + + // Copy the bytes before releasing the CoreFoundation property. + return append(net.HardwareAddr(nil), unsafe.Slice((*byte)(data), 6)...), nil +} diff --git a/pkg/util/machine/mac_address_darwin_test.go b/pkg/util/machine/mac_address_darwin_test.go new file mode 100644 index 0000000..054f04c --- /dev/null +++ b/pkg/util/machine/mac_address_darwin_test.go @@ -0,0 +1,237 @@ +//go:build darwin && !ios + +package machine + +import ( + "errors" + "net" + "unsafe" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("macOS hardware address", func() { + var networkInterface net.Interface + + BeforeEach(func() { + networkInterface = net.Interface{ + Name: "en4", + HardwareAddr: net.HardwareAddr{0x02, 0, 0, 0, 0, 0}, + } + }) + + It("preserves an existing unredacted identity without consulting IOKit", func() { + networkInterface.HardwareAddr = net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff} + + address, err := macAddressWithHardwareLookup(networkInterface, func(string) (net.HardwareAddr, error) { + Fail("unexpected IOKit lookup") + + return nil, nil + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(address).To(Equal("aa:bb:cc:dd:ee:ff")) + }) + + It("recovers the hardware address of the selected interface when redacted", func() { + address, err := macAddressWithHardwareLookup(networkInterface, func(name string) (net.HardwareAddr, error) { + Expect(name).To(Equal("en4")) + + return net.HardwareAddr{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff}, nil + }) + + Expect(err).ToNot(HaveOccurred()) + Expect(address).To(Equal("aa:bb:cc:dd:ee:ff")) + }) + + It("reports unavailable hardware information without returning the placeholder", func() { + lookupErr := errors.New("interface unavailable") + address, err := macAddressWithHardwareLookup(networkInterface, func(string) (net.HardwareAddr, error) { + return nil, lookupErr + }) + + Expect(address).To(BeEmpty()) + Expect(errors.Is(err, lookupErr)).To(BeTrue()) + Expect(err.Error()).To(ContainSubstring("macOS redacted the mac address for \"en4\"")) + }) + + DescribeTable("rejects an unusable IOKit address", func(hardware net.HardwareAddr) { + address, err := macAddressWithHardwareLookup(networkInterface, func(string) (net.HardwareAddr, error) { + return hardware, nil + }) + + Expect(address).To(BeEmpty()) + Expect(err).To(MatchError(ContainSubstring("IOKit returned no usable hardware address"))) + }, + Entry("missing", nil), + Entry("wrong length", net.HardwareAddr{0xaa, 0xbb}), + Entry("zero", net.HardwareAddr{0, 0, 0, 0, 0, 0}), + Entry("redacted", net.HardwareAddr{0x02, 0, 0, 0, 0, 0}), + Entry("broadcast", net.HardwareAddr{0xff, 0xff, 0xff, 0xff, 0xff, 0xff}), + Entry("multicast", net.HardwareAddr{0x01, 0, 0x5e, 0, 0, 1}), + ) +}) + +var _ = Describe("IOKit hardware address lookup", func() { + const ( + dictionary = uintptr(1) + service = uint32(2) + key = uintptr(3) + property = uintptr(4) + dataType = uintptr(5) + ) + + var ( + api macAddressAPI + data []byte + released []uintptr + releasedObjects []uint32 + ) + + BeforeEach(func() { + data = []byte{0xaa, 0xbb, 0xcc, 0xdd, 0xee, 0xff} + released = nil + releasedObjects = nil + api = macAddressAPI{ + matching: func(port, options uint32, name string) uintptr { + Expect(port).To(BeZero()) + Expect(options).To(BeZero()) + Expect(name).To(Equal("en4")) + + return dictionary + }, + service: func(port uint32, matching uintptr) uint32 { + Expect(port).To(BeZero()) + Expect(matching).To(Equal(dictionary)) + + return service + }, + searchProperty: func(object uint32, plane string, propertyKey, allocator uintptr, options uint32) uintptr { + Expect(object).To(Equal(service)) + Expect(plane).To(Equal("IOService")) + Expect(propertyKey).To(Equal(key)) + Expect(allocator).To(BeZero()) + Expect(options).To(Equal(uint32(3))) + + return property + }, + releaseObject: func(object uint32) int32 { + releasedObjects = append(releasedObjects, object) + + return 0 + }, + createString: func(allocator uintptr, value string, encoding uint32) uintptr { + Expect(allocator).To(BeZero()) + Expect(value).To(Equal("IOMACAddress")) + Expect(encoding).To(Equal(uint32(0x08000100))) + + return key + }, + release: func(object uintptr) { + released = append(released, object) + if object == property { + clear(data) + } + }, + typeID: func(object uintptr) uintptr { + Expect(object).To(Equal(property)) + + return dataType + }, + dataTypeID: func() uintptr { return dataType }, + dataLength: func(object uintptr) int64 { + Expect(object).To(Equal(property)) + + return int64(len(data)) + }, + dataBytes: func(object uintptr) unsafe.Pointer { + Expect(object).To(Equal(property)) + + return unsafe.Pointer(&data[0]) + }, + } + }) + + It("copies the address before releasing the property, key, and interface", func() { + address, err := readHardwareMacAddress("en4", api) + + Expect(err).ToNot(HaveOccurred()) + Expect(address.String()).To(Equal("aa:bb:cc:dd:ee:ff")) + Expect(released).To(Equal([]uintptr{property, key})) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) + + It("handles a missing matching dictionary", func() { + api.matching = func(uint32, uint32, string) uintptr { return 0 } + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("could not create interface matching dictionary")) + Expect(released).To(BeEmpty()) + Expect(releasedObjects).To(BeEmpty()) + }) + + It("handles a missing interface without releasing the consumed dictionary twice", func() { + api.service = func(uint32, uintptr) uint32 { return 0 } + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("network interface was not found in IOKit")) + Expect(released).To(BeEmpty()) + Expect(releasedObjects).To(BeEmpty()) + }) + + It("releases the interface when the property key cannot be created", func() { + api.createString = func(uintptr, string, uint32) uintptr { return 0 } + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("could not create hardware address property key")) + Expect(released).To(BeEmpty()) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) + + It("releases the interface and key when the property is missing", func() { + api.searchProperty = func(uint32, string, uintptr, uintptr, uint32) uintptr { return 0 } + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("hardware address property was not found in IOKit")) + Expect(released).To(Equal([]uintptr{key})) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) + + It("rejects a non-data property without accessing its bytes", func() { + api.typeID = func(uintptr) uintptr { return dataType + 1 } + api.dataLength = nil + api.dataBytes = nil + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("IOKit hardware address must contain six bytes")) + Expect(released).To(Equal([]uintptr{property, key})) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) + + It("rejects an unexpected length without accessing the bytes", func() { + api.dataLength = func(uintptr) int64 { return 8 } + api.dataBytes = nil + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("IOKit hardware address must contain six bytes")) + Expect(released).To(Equal([]uintptr{property, key})) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) + + It("handles missing data without dereferencing a null pointer", func() { + api.dataBytes = func(uintptr) unsafe.Pointer { return nil } + + _, err := readHardwareMacAddress("en4", api) + + Expect(err).To(MatchError("IOKit hardware address data is missing")) + Expect(released).To(Equal([]uintptr{property, key})) + Expect(releasedObjects).To(Equal([]uint32{service})) + }) +}) diff --git a/pkg/util/machine/mac_address_default.go b/pkg/util/machine/mac_address_default.go new file mode 100644 index 0000000..1a5b1bf --- /dev/null +++ b/pkg/util/machine/mac_address_default.go @@ -0,0 +1,9 @@ +//go:build !darwin || ios + +package machine + +import "net" + +func interfaceMacAddress(networkInterface net.Interface) (string, error) { + return networkInterface.HardwareAddr.String(), nil +} diff --git a/pkg/util/machine/machine.go b/pkg/util/machine/machine.go index d2376e2..14796c4 100644 --- a/pkg/util/machine/machine.go +++ b/pkg/util/machine/machine.go @@ -44,7 +44,7 @@ func (*machine) MacAddress() (string, error) { for _, netInterface := range interfaces { addr := netInterface.HardwareAddr.String() if addr != "" { - return addr, nil + return interfaceMacAddress(netInterface) } } diff --git a/pkg/util/machine/machine_test.go b/pkg/util/machine/machine_test.go index 43e6e2f..887f039 100644 --- a/pkg/util/machine/machine_test.go +++ b/pkg/util/machine/machine_test.go @@ -1,6 +1,7 @@ package machine import ( + "runtime" "syscall" "testing" @@ -48,6 +49,10 @@ var _ = Describe("Machine", func() { res, err := machine.MacAddress() Expect(err).ToNot(HaveOccurred()) Expect(res).To(ContainSubstring(":")) + + if runtime.GOOS == "darwin" { + Expect(res).ToNot(Equal("02:00:00:00:00:00")) + } }) }) From 05418556280f18b5aa2f57dfa99e1e0f1d2c6827 Mon Sep 17 00:00:00 2001 From: pdx15 <301492+pdx15@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:28:24 +0000 Subject: [PATCH 15/18] fix: allow downloads of delisted tvos apps Cherry-pick of upstream 387d1a4 (adapted to the fork layout): - platform version lookup failures are now sentinel errors (errPlatformAppNotFound / errPlatformOffersNotFound) and appstore.Error gained Unwrap so errors.Is reaches them through metadata wrappers - Lookup exposes ErrAppNotFound - Download no longer aborts when a tvOS app has no catalog offer: the package is fetched anyway and its declared platform is validated - validatePackagePlatform now runs against the raw .tmp package before patching/renaming, and the .tmp file is removed on failure, so a platform mismatch leaves the previously downloaded file intact - download command falls back to the numeric app ID (with the bundle ID kept on the app) when the bundle lookup reports the app as not found - tests: new 'Downloading delisted tvOS apps' suite (adapted: the fork's redownload endpoint is hardcoded instead of coming from the bag, and the ensureSinfs guard adds a primary+redownload re-request) and a new cmd/download_test.go covering app resolution (adapted to the fork's downloadCmd + dependencies.AppStore injection) Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- cmd/download.go | 11 +- cmd/download_test.go | 104 ++++++++++++ pkg/appstore/appstore_download.go | 22 ++- pkg/appstore/appstore_download_test.go | 158 ++++++++++++++++++ pkg/appstore/appstore_lookup.go | 4 +- pkg/appstore/appstore_lookup_test.go | 2 +- .../appstore_platform_version_lookup.go | 9 +- pkg/appstore/error.go | 4 + 8 files changed, 300 insertions(+), 14 deletions(-) create mode 100644 cmd/download_test.go diff --git a/cmd/download.go b/cmd/download.go index 17dfd3a..38bb0d4 100644 --- a/cmd/download.go +++ b/cmd/download.go @@ -41,18 +41,21 @@ func downloadCmd() *cobra.Command { return err } - app := appstore.App{ID: appID} + app := appstore.App{ID: appID, BundleID: bundleID} if bundleID != "" { lookupResult, err := dependencies.AppStore.Lookup(appstore.LookupInput{ Account: acc, BundleID: bundleID, Platform: platform, }) - if err != nil { + if err != nil && (appID == 0 || !errors.Is(err, appstore.ErrAppNotFound)) { return err } - app = lookupResult.App + // Delisted apps may still be downloadable by their numeric ID. + if err == nil { + app = lookupResult.App + } } interactive, _ := cmd.Context().Value(interactiveKey).(bool) @@ -108,7 +111,7 @@ func downloadCmd() *cobra.Command { } cmd.Flags().Int64VarP(&appID, "app-id", "i", 0, "ID of the target iOS app (required)") - cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID)") + cmd.Flags().StringVarP(&bundleID, "bundle-identifier", "b", "", "The bundle identifier of the target iOS app (overrides the app ID when found)") cmd.Flags().StringVarP(&outputPath, "output", "o", "", "The destination path of the downloaded app package") cmd.Flags().StringVar(&externalVersionID, "external-version-id", "", "External version identifier of the target iOS app (defaults to latest version when not specified)") cmd.Flags().StringVar(&platformValue, "platform", "", "Platform to download for: iphone, ipad, or appletv") diff --git a/cmd/download_test.go b/cmd/download_test.go new file mode 100644 index 0000000..6fb6746 --- /dev/null +++ b/cmd/download_test.go @@ -0,0 +1,104 @@ +package cmd + +import ( + "context" + "errors" + "fmt" + + "github.com/majd/ipatool/v2/pkg/appstore" + "github.com/majd/ipatool/v2/pkg/log" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("Download command", func() { + Describe("app resolution", func() { + var store *fakeDownloadAppStore + + BeforeEach(func() { + store = &fakeDownloadAppStore{account: appstore.Account{StoreFront: "143441"}} + previousDependencies := dependencies + DeferCleanup(func() { dependencies = previousDependencies }) + dependencies.AppStore = store + dependencies.Logger = log.NewLogger(log.Args{}) + }) + + execute := func(args ...string) error { + cmd := downloadCmd() + cmd.SetArgs(args) + cmd.SetContext(context.WithValue(context.Background(), interactiveKey, false)) + + return cmd.Execute() + } + + It("uses the app ID when the bundle is absent from the catalog", func() { + store.lookupError = fmt.Errorf("lookup: %w", appstore.ErrAppNotFound) + Expect(execute("-i", "42", "-b", "com.example.delisted", "--platform", "appletv")).To(Succeed()) + Expect(store.lookupInputs).To(Equal([]appstore.LookupInput{{ + Account: appstore.Account{StoreFront: "143441"}, + BundleID: "com.example.delisted", + Platform: appstore.PlatformAppleTV, + }})) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(appstore.App{ID: 42, BundleID: "com.example.delisted"})) + Expect(store.downloadInputs[0].Platform).To(Equal(appstore.PlatformAppleTV)) + }) + + It("preserves bundle identifier precedence when lookup succeeds", func() { + store.lookupOutput.App = appstore.App{ID: 43, BundleID: "com.example.listed"} + Expect(execute("-i", "42", "-b", "com.example.listed")).To(Succeed()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(store.lookupOutput.App)) + }) + + It("resolves a bundle identifier without an app ID", func() { + store.lookupOutput.App = appstore.App{ID: 43, BundleID: "com.example.listed"} + Expect(execute("-b", "com.example.listed")).To(Succeed()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(store.lookupOutput.App)) + }) + + It("does not look up the bundle for an explicit app ID and version", func() { + Expect(execute("-i", "42", "--platform", "appletv", "--external-version-id", "123456")).To(Succeed()) + Expect(store.lookupInputs).To(BeEmpty()) + Expect(store.downloadInputs).To(HaveLen(1)) + Expect(store.downloadInputs[0].App).To(Equal(appstore.App{ID: 42})) + Expect(store.downloadInputs[0].ExternalVersionID).To(Equal("123456")) + }) + + DescribeTable("preserves lookup errors", func(args []string, lookupError error) { + store.lookupError = lookupError + Expect(execute(args...)).To(MatchError(lookupError)) + Expect(store.downloadInputs).To(BeEmpty()) + }, + Entry("missing bundle without an app ID", []string{"-b", "com.example.delisted"}, appstore.ErrAppNotFound), + Entry("request failure with an app ID", []string{"-i", "42", "-b", "com.example.app"}, errors.New("request failed")), + ) + }) +}) + +type fakeDownloadAppStore struct { + appstore.AppStore + account appstore.Account + downloadInputs []appstore.DownloadInput + lookupInputs []appstore.LookupInput + lookupOutput appstore.LookupOutput + lookupError error +} + +func (s *fakeDownloadAppStore) AccountInfo() (appstore.AccountInfoOutput, error) { + return appstore.AccountInfoOutput{Account: s.account}, nil +} +func (s *fakeDownloadAppStore) Lookup(input appstore.LookupInput) (appstore.LookupOutput, error) { + s.lookupInputs = append(s.lookupInputs, input) + + return s.lookupOutput, s.lookupError +} +func (s *fakeDownloadAppStore) Download(input appstore.DownloadInput) (appstore.DownloadOutput, error) { + s.downloadInputs = append(s.downloadInputs, input) + + return appstore.DownloadOutput{DestinationPath: "/tmp/delisted-test.ipa"}, nil +} +func (*fakeDownloadAppStore) ReplicateSinf(appstore.ReplicateSinfInput) error { + return nil +} diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 6df81dd..27e6103 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -268,7 +268,11 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { externalVersionID := input.ExternalVersionID if externalVersionID == "" && input.Platform == PlatformAppleTV { externalVersionID, err = t.lookupLatestExternalVersionID(input.Account, input.App, input.Platform) - if err != nil { + // Delisted tvOS apps may have no catalog offer but still be available + // for redownload. Validate the returned package's platform below. + missingTVOffer := input.Platform == PlatformAppleTV && + (errors.Is(err, errPlatformAppNotFound) || errors.Is(err, errPlatformOffersNotFound)) + if err != nil && !missingTVOffer { return DownloadOutput{}, fmt.Errorf("failed to resolve platform version: %w", err) } } @@ -309,6 +313,17 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to download file: %w", err) } + // Validate the raw package before touching the destination, so a + // platform mismatch leaves any previously downloaded file intact. + err = t.validatePackagePlatform(tmpPath, input.Platform) + if err != nil { + if removeErr := t.os.Remove(tmpPath); removeErr != nil { + err = errors.Join(err, fmt.Errorf("failed to remove invalid package: %w", removeErr)) + } + + return DownloadOutput{}, fmt.Errorf("failed to validate package platform: %w", err) + } + artwork, err := t.downloadArtwork(context.Background(), item.ArtworkURL) if err != nil { return DownloadOutput{}, fmt.Errorf("failed to download artwork: %w", err) @@ -319,11 +334,6 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { return DownloadOutput{}, fmt.Errorf("failed to apply patches: %w", err) } - err = t.validatePackagePlatform(destination, input.Platform) - if err != nil { - return DownloadOutput{}, fmt.Errorf("failed to validate package platform: %w", err) - } - // Read Info.plist once to extract app name and iOS version originalDestination := destination if info, readErr := t.readInfoFromIPA(destination); readErr == nil { diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 5285f78..0b7af56 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -2,6 +2,7 @@ package appstore import ( "archive/zip" + "bytes" "errors" "fmt" "io" @@ -9,6 +10,7 @@ import ( gohttp "net/http" "net/url" "os" + "path/filepath" "strings" "time" @@ -760,3 +762,159 @@ var _ = Describe("AppStore (Download)", func() { }) }) }) + +var _ = Describe("Downloading delisted tvOS apps", func() { + var ( + platformClient *http.MockClient[platformVersionLookupResult] + downloadClient *http.MockClient[downloadResult] + httpClient *http.MockClient[interface{}] + store *appstore + input DownloadInput + ) + + missingApp := platformVersionLookupResult{} + missingOffers := platformVersionLookupResult{Results: map[string]platformVersionLookupItem{"42": {}}} + missingVersion := platformVersionLookupResult{Results: map[string]platformVersionLookupItem{ + "42": {Offers: []platformVersionLookupOffer{{}}}, + }} + + BeforeEach(func() { + ctrl := gomock.NewController(GinkgoT()) + platformClient = http.NewMockClient[platformVersionLookupResult](ctrl) + downloadClient = http.NewMockClient[downloadResult](ctrl) + httpClient = http.NewMockClient[interface{}](ctrl) + mockMachine := machine.NewMockMachine(ctrl) + mockMachine.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil) + store = &appstore{ + platformClient: platformClient, + downloadClient: downloadClient, + httpClient: httpClient, + machine: mockMachine, + os: operatingsystem.New(), + } + input = DownloadInput{ + Account: Account{StoreFront: "143441"}, + App: App{ID: 42}, + Platform: PlatformAppleTV, + OutputPath: filepath.Join(GinkgoT().TempDir(), "app.ipa"), + } + }) + + DescribeTable("attempts downloads without a catalog offer and validates the package", + func(catalog platformVersionLookupResult, redownload bool, supportedPlatform string) { + previousOutput := []byte("previous output") + Expect(os.WriteFile(input.OutputPath, previousOutput, 0600)).To(Succeed()) + packageBuffer := new(bytes.Buffer) + writer := zip.NewWriter(packageBuffer) + infoWriter, err := writer.Create("Payload/Test.app/Info.plist") + Expect(err).ToNot(HaveOccurred()) + info, err := plist.Marshal(map[string]interface{}{ + "CFBundleSupportedPlatforms": []string{supportedPlatform}, + }, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + _, err = infoWriter.Write(info) + Expect(err).ToNot(HaveOccurred()) + Expect(writer.Close()).To(Succeed()) + + successData := downloadResult{Items: []downloadItemResult{{ + URL: "https://example.test/app.ipa", + Metadata: map[string]interface{}{"bundleShortVersionString": "1.0"}, + }}} + + previous := platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{ + StatusCode: gohttp.StatusOK, Data: catalog, + }, nil) + + // The raw descriptor carries no sinfs, so the fork's ensureSinfs + // guard re-requests the primary and redownload endpoints once. + checkRequest := func(req http.Request) { + payload := req.Payload.(*http.XMLPayload).Content + Expect(payload).To(HaveKeyWithValue("salableAdamId", input.App.ID)) + Expect(payload).ToNot(HaveKey("externalVersionId")) + Expect(payload).ToNot(HaveKey("appExtVrsId")) + } + primary := func(data downloadResult) *gomock.Call { + return downloadClient.EXPECT().Send(gomock.Any()).Do(checkRequest).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, Data: data, + }, nil) + } + redownload := func(data downloadResult) *gomock.Call { + return downloadClient.EXPECT().Send(gomock.Any()).Do(checkRequest).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, Data: data, + }, nil) + } + + var calls []*gomock.Call + if redownload { + // Empty primary responses fall through to the redownload endpoint. + calls = append(calls, primary(downloadResult{}), primary(downloadResult{}), redownload(successData)) + } else { + calls = append(calls, primary(successData)) + } + calls = append(calls, primary(successData), redownload(successData)) + gomock.InOrder(append([]*gomock.Call{previous}, calls...)) + + httpClient.EXPECT().NewRequest("GET", "https://example.test/app.ipa", nil). + Return(&gohttp.Request{Header: gohttp.Header{}}, nil) + httpClient.EXPECT().Do(gomock.Any()).Return(&gohttp.Response{ + StatusCode: gohttp.StatusOK, + Body: io.NopCloser(bytes.NewReader(packageBuffer.Bytes())), + ContentLength: int64(packageBuffer.Len()), + }, nil) + + out, err := store.Download(input) + if supportedPlatform == "AppleTVOS" { + Expect(err).ToNot(HaveOccurred()) + Expect(out.DestinationPath).To(Equal(input.OutputPath)) + Expect(store.validatePackagePlatform(out.DestinationPath, PlatformAppleTV)).To(Succeed()) + } else { + Expect(err).To(MatchError(ContainSubstring("does not declare AppleTVOS support"))) + Expect(os.ReadFile(input.OutputPath)).To(Equal(previousOutput)) + } + Expect(input.OutputPath + ".tmp").ToNot(BeAnExistingFile()) + }, + Entry("missing app", missingApp, false, "AppleTVOS"), + Entry("missing offers", missingOffers, false, "AppleTVOS"), + Entry("missing app with redownload", missingApp, true, "AppleTVOS"), + Entry("wrong platform from volumeStore", missingApp, false, "iPhoneOS"), + Entry("wrong platform from redownload", missingApp, true, "iPhoneOS"), + ) + + DescribeTable("preserves catalog failures", func(status int, catalog platformVersionLookupResult, sendErr error, message string) { + platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{ + StatusCode: status, Data: catalog, + }, sendErr) + + _, err := store.Download(input) + Expect(err).To(MatchError(ContainSubstring(message))) + }, + Entry("network failure", 0, missingApp, errors.New("connection reset"), "connection reset"), + Entry("invalid response", 200, missingApp, errors.New("invalid JSON"), "invalid JSON"), + Entry("HTTP failure", 503, missingApp, nil, "platform version lookup request failed"), + Entry("offer without version", 200, missingVersion, nil, "no external version id"), + ) + + It("preserves license errors after a missing catalog entry", func() { + platformClient.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil) + downloadClient.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{FailureType: FailureTypeLicenseNotFound}, + }, nil) + + _, err := store.Download(input) + Expect(err).To(MatchError(ErrLicenseRequired)) + }) + + It("uses an explicit tvOS version without consulting the catalog", func() { + input.ExternalVersionID = "123456" + downloadClient.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", input.ExternalVersionID)) + }).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{FailureType: FailureTypeLicenseNotFound}, + }, nil) + + _, err := store.Download(input) + Expect(err).To(MatchError(ErrLicenseRequired)) + }) +}) diff --git a/pkg/appstore/appstore_lookup.go b/pkg/appstore/appstore_lookup.go index 02570c2..19c3fa5 100644 --- a/pkg/appstore/appstore_lookup.go +++ b/pkg/appstore/appstore_lookup.go @@ -9,6 +9,8 @@ import ( "github.com/majd/ipatool/v2/pkg/http" ) +var ErrAppNotFound = errors.New("app not found") + type LookupInput struct { Account Account BundleID string @@ -41,7 +43,7 @@ func (t *appstore) Lookup(input LookupInput) (LookupOutput, error) { } if len(res.Data.Results) == 0 { - return LookupOutput{}, errors.New("app not found") + return LookupOutput{}, ErrAppNotFound } return LookupOutput{ diff --git a/pkg/appstore/appstore_lookup_test.go b/pkg/appstore/appstore_lookup_test.go index c0b4526..7c3a3da 100644 --- a/pkg/appstore/appstore_lookup_test.go +++ b/pkg/appstore/appstore_lookup_test.go @@ -49,7 +49,7 @@ var _ = Describe("AppStore (Lookup)", func() { StoreFront: "143441", }, }) - Expect(err).To(HaveOccurred()) + Expect(errors.Is(err, ErrAppNotFound)).To(BeTrue()) }) }) diff --git a/pkg/appstore/appstore_platform_version_lookup.go b/pkg/appstore/appstore_platform_version_lookup.go index 3631bd3..f8588c0 100644 --- a/pkg/appstore/appstore_platform_version_lookup.go +++ b/pkg/appstore/appstore_platform_version_lookup.go @@ -12,6 +12,11 @@ import ( "github.com/majd/ipatool/v2/pkg/http" ) +var ( + errPlatformAppNotFound = errors.New("platform version lookup returned no app") + errPlatformOffersNotFound = errors.New("platform version lookup returned no offers") +) + type platformVersionLookupResult struct { Results map[string]platformVersionLookupItem `json:"results,omitempty"` } @@ -98,13 +103,13 @@ func (t *appstore) lookupLatestExternalVersionID(acc Account, app App, platform item, ok := res.Data.Results[strconv.FormatInt(app.ID, 10)] if !ok { - lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no app"), res) + lastErr = NewErrorWithMetadata(errPlatformAppNotFound, res) continue } if len(item.Offers) == 0 { - lastErr = NewErrorWithMetadata(errors.New("platform version lookup returned no offers"), res) + lastErr = NewErrorWithMetadata(errPlatformOffersNotFound, res) continue } diff --git a/pkg/appstore/error.go b/pkg/appstore/error.go index f35542a..e920fef 100644 --- a/pkg/appstore/error.go +++ b/pkg/appstore/error.go @@ -9,6 +9,10 @@ func (t Error) Error() string { return t.underlyingError.Error() } +func (t Error) Unwrap() error { + return t.underlyingError +} + func NewErrorWithMetadata(err error, metadata interface{}) *Error { return &Error{ underlyingError: err, From f70dbfdc01868dabca140a17a65fd8aad4590b70 Mon Sep 17 00:00:00 2001 From: pdx15 <301492+pdx15@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:39:12 +0000 Subject: [PATCH 16/18] fix: recover downloads via the bag updateProduct endpoint Ports the endpoint-fallback mechanism that upstream introduced in 30b2909 (and extended in 747d66f for macOS and acd9e7a for tvOS), which the fork skipped when it kept its own download flow: - urlBag now parses the redownloadProduct/updateProduct endpoints and fetchURLBag returns the raw bag without the auth-specific SAP config - new appstore_download_product.go: sendUpdateProduct asks the bag's updateProduct endpoint for a pinned version (appExtVrsId) and verifies the response (single item, matching app id / version / bundle id); bag endpoints are validated against the trusted download dispatch domain - fetchDownloadItem takes the platform and, after the redownload endpoint gives up on a pinned version (empty HTTP 500 or a message-only 'no longer available' response), falls back to updateProduct for iphone, ipad, macos, tvOS and unspecified platforms - the fork's redownload URL and its primary-retry + ensureSinfs behaviour are preserved, and the bag is only fetched when the update fallback is actually needed, so existing download/check-download flows and tests are unchanged - new appstore_update_product_test.go covering the fallback: empty-500 and unavailable tvOS pins, unpinned availability responses left as-is, response mismatch and untrusted-endpoint rejection, license error propagation, and missing update endpoint Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_bag.go | 19 +- pkg/appstore/appstore_download.go | 58 ++++- pkg/appstore/appstore_download_product.go | 119 +++++++++++ pkg/appstore/appstore_update_product_test.go | 209 +++++++++++++++++++ 4 files changed, 400 insertions(+), 5 deletions(-) create mode 100644 pkg/appstore/appstore_download_product.go create mode 100644 pkg/appstore/appstore_update_product_test.go diff --git a/pkg/appstore/appstore_bag.go b/pkg/appstore/appstore_bag.go index 29108a2..d9a12bc 100644 --- a/pkg/appstore/appstore_bag.go +++ b/pkg/appstore/appstore_bag.go @@ -42,7 +42,24 @@ type bagResult struct { } type urlBag struct { - AuthEndpoint string `plist:"authenticateAccount,omitempty"` + AuthEndpoint string `plist:"authenticateAccount,omitempty"` + RedownloadEndpoint string `plist:"redownloadProduct,omitempty"` + UpdateEndpoint string `plist:"updateProduct,omitempty"` +} + +// fetchURLBag returns the raw endpoint bag. Download fallbacks only need the +// endpoints and do not require the authentication-specific SAP configuration. +func (t *appstore) fetchURLBag(guid string) (urlBag, error) { + res, err := t.bagClient.Send(t.bagRequest(guid)) + if err != nil { + return urlBag{}, fmt.Errorf("failed to send http request: %w", err) + } + + if res.StatusCode != gohttp.StatusOK { + return urlBag{}, fmt.Errorf("received unexpected status code: %d", res.StatusCode) + } + + return res.Data.URLBag, nil } func (*appstore) bagRequest(guid string) http.Request { diff --git a/pkg/appstore/appstore_download.go b/pkg/appstore/appstore_download.go index 27e6103..32c1ba4 100644 --- a/pkg/appstore/appstore_download.go +++ b/pkg/appstore/appstore_download.go @@ -81,7 +81,7 @@ func (t *appstore) CheckDownload(input CheckDownloadInput) (CheckDownloadOutput, } } - item, err := t.fetchDownloadItem(input.Account, input.App, guid, externalVersionID) + item, err := t.fetchDownloadItem(input.Account, input.App, guid, externalVersionID, input.Platform) if err != nil { return CheckDownloadOutput{}, err } @@ -109,8 +109,11 @@ func (t *appstore) CheckDownload(input CheckDownloadInput) (CheckDownloadOutput, // It is shared by Download and CheckDownload so both apply exactly the same // fallback chain: when volumeStoreDownloadProduct answers with an empty // Items[] (Chrome, Instagram, Microsoft Teams, ...) the request is retried -// once and then the redownload endpoint is consulted. -func (t *appstore) fetchDownloadItem(acc Account, app App, guid string, externalVersionID string) (downloadItemResult, error) { +// once and then the redownload endpoint is consulted. If the redownload +// endpoint cannot serve the pinned version (empty HTTP 500 or a +// "no longer available" message), the bag's updateProduct endpoint is the +// last fallback. +func (t *appstore) fetchDownloadItem(acc Account, app App, guid string, externalVersionID string, platform Platform) (downloadItemResult, error) { req := t.downloadRequest(acc, app, guid, externalVersionID) res, err := t.downloadClient.Send(req) @@ -136,6 +139,15 @@ func (t *appstore) fetchDownloadItem(acc Account, app App, guid string, external if isEmptyResponseError(err) { redownloadReq := t.redownloadRequest(acc, app, guid, externalVersionID) redownloadRes, redownloadErr := t.downloadClient.Send(redownloadReq) + + if item, updateErr, consulted := t.tryUpdateProduct(acc, app, guid, externalVersionID, platform, redownloadRes, redownloadErr); consulted { + if updateErr != nil { + return downloadItemResult{}, updateErr + } + + return t.ensureSinfs(item, req, acc, app, guid, externalVersionID), nil + } + if redownloadErr != nil { return downloadItemResult{}, fmt.Errorf("failed to send redownload request: %w", redownloadErr) } @@ -152,6 +164,44 @@ func (t *appstore) fetchDownloadItem(acc Account, app App, guid string, external return t.ensureSinfs(item, req, acc, app, guid, externalVersionID), nil } +// tryUpdateProduct consults the bag's updateProduct endpoint for the pinned +// version after the redownload endpoint failed to serve it. It reports whether +// the update endpoint was consulted; when it was, updateErr is the final +// outcome of the whole download attempt. +func (t *appstore) tryUpdateProduct(acc Account, app App, guid, externalVersionID string, platform Platform, redownloadRes http.Result[downloadResult], redownloadErr error) (downloadItemResult, error, bool) { + if externalVersionID == "" { + return downloadItemResult{}, nil, false + } + + if platform != "" && platform != PlatformIPhone && platform != PlatformIPad && + platform != PlatformMacOS && platform != PlatformAppleTV { + return downloadItemResult{}, nil, false + } + + gaveUp := isEmptyRedownloadError(redownloadErr) || + (redownloadErr == nil && isUnavailableDownloadProductResponse(redownloadRes)) + if !gaveUp { + return downloadItemResult{}, nil, false + } + + bag, err := t.fetchURLBag(guid) + if err != nil || bag.UpdateEndpoint == "" { + return downloadItemResult{}, nil, false + } + + updateRes, updateErr := t.sendUpdateProduct(bag.UpdateEndpoint, acc, app, guid, externalVersionID) + if updateErr != nil { + return downloadItemResult{}, updateErr, true + } + + item, err := classifyDownloadResponse(updateRes) + if err != nil { + return downloadItemResult{}, err, true + } + + return item, nil, true +} + // ensureSinfs guards against a valid download descriptor that carries no DRM // signatures at all (seen with com.google.GoogleMobile). Such an IPA cannot // be installed on a device, so before spending the bandwidth try to obtain a @@ -286,7 +336,7 @@ func (t *appstore) Download(input DownloadInput) (DownloadOutput, error) { } } - item, err := t.fetchDownloadItem(input.Account, input.App, guid, externalVersionID) + item, err := t.fetchDownloadItem(input.Account, input.App, guid, externalVersionID, input.Platform) if err != nil { return DownloadOutput{}, err } diff --git a/pkg/appstore/appstore_download_product.go b/pkg/appstore/appstore_download_product.go new file mode 100644 index 0000000..c850116 --- /dev/null +++ b/pkg/appstore/appstore_download_product.go @@ -0,0 +1,119 @@ +package appstore + +import ( + "errors" + "fmt" + gohttp "net/http" + "net/url" + "strings" + + "github.com/majd/ipatool/v2/pkg/http" +) + +const ( + downloadDispatchDomain = "downloaddispatch." + iTunesAPIDomain + updateProductPath = "/up/updateProduct" +) + +// isEmptyRedownloadError reports a redownload endpoint failure that carries no +// payload at all: an empty HTTP 500 body. +func isEmptyRedownloadError(err error) bool { + var unexpected *http.UnexpectedResponseError + + return errors.As(err, &unexpected) && + unexpected.StatusCode == gohttp.StatusInternalServerError && unexpected.Snippet == "" +} + +// Limit recovery to the observed availability response; other customer messages +// and structured failures must retain their normal error handling. +func isUnavailableDownloadProductResponse(res http.Result[downloadResult]) bool { + message := strings.ToLower(strings.TrimSpace(res.Data.CustomerMessage)) + + return res.StatusCode == gohttp.StatusOK && + res.Data.FailureType == "" && len(res.Data.Items) == 0 && + (message == "no longer available" || strings.HasSuffix(message, " no longer available")) +} + +// sendUpdateProduct asks the bag's updateProduct endpoint for a pinned +// version. It can serve pinned iOS, macOS, and tvOS versions when redownload +// returns an empty HTTP 500 or a message-only availability error. The request +// keeps the same session and version selection as the redownload attempt. +func (t *appstore) sendUpdateProduct(endpoint string, acc Account, app App, guid, externalVersionID string) (http.Result[downloadResult], error) { + update, err := newDownloadEndpoint(endpoint, updateProductPath) + if err != nil { + return http.Result[downloadResult]{}, err + } + + payload := map[string]interface{}{ + "creditDisplay": "", + "guid": guid, + "salableAdamId": app.ID, + "serialNumber": "0", + "appExtVrsId": externalVersionID, + } + + res, err := t.downloadClient.Send(http.Request{ + URL: fmt.Sprintf("%s?guid=%s", update.baseURL, guid), + Method: http.MethodPOST, + ResponseFormat: http.ResponseFormatXML, + Headers: map[string]string{ + "Content-Type": "application/x-apple-plist", + "iCloud-DSID": acc.DirectoryServicesID, + "X-Dsid": acc.DirectoryServicesID, + }, + Payload: &http.XMLPayload{ + Content: payload, + }, + }) + if err != nil { + return res, fmt.Errorf("failed to send update request: %w", err) + } + + if res.Data.FailureType != "" { + return res, nil + } + + if res.Data.CustomerMessage != "" { + return res, NewErrorWithMetadata(fmt.Errorf("received update error: %s", res.Data.CustomerMessage), res) + } + + if res.StatusCode != gohttp.StatusOK { + return res, fmt.Errorf("received unexpected update status code: %d", res.StatusCode) + } + + if len(res.Data.Items) != 1 { + return res, errors.New("update response must contain exactly one item") + } + + metadata := res.Data.Items[0].Metadata + if fmt.Sprint(metadata["itemId"]) != fmt.Sprint(app.ID) || + fmt.Sprint(metadata["softwareVersionExternalIdentifier"]) != externalVersionID { + return res, errors.New("update response does not match the requested app or version") + } + + bundleID, ok := metadata["softwareVersionBundleId"].(string) + if !ok || bundleID == "" || (app.BundleID != "" && bundleID != app.BundleID) { + return res, errors.New("update response does not match the requested bundle identifier") + } + + return res, nil +} + +type downloadProductEndpoint struct { + baseURL string +} + +// newDownloadEndpoint validates a bag-provided download endpoint. Only the +// trusted download dispatch domain is accepted. +func newDownloadEndpoint(endpoint, path string) (downloadProductEndpoint, error) { + parsed, err := url.ParseRequestURI(endpoint) + if err != nil || parsed.Scheme != "https" || parsed.Host != downloadDispatchDomain || + parsed.Path != path || parsed.RawPath != "" || parsed.RawQuery != "" || + parsed.ForceQuery || parsed.Fragment != "" || parsed.User != nil { + return downloadProductEndpoint{}, errors.New("invalid download endpoint in bag") + } + + return downloadProductEndpoint{ + baseURL: endpoint, + }, nil +} diff --git a/pkg/appstore/appstore_update_product_test.go b/pkg/appstore/appstore_update_product_test.go new file mode 100644 index 0000000..0cd610e --- /dev/null +++ b/pkg/appstore/appstore_update_product_test.go @@ -0,0 +1,209 @@ +package appstore + +import ( + gohttp "net/http" + + "github.com/majd/ipatool/v2/pkg/http" + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" + "go.uber.org/mock/gomock" +) + +var _ = Describe("AppStore (Update Product fallback)", func() { + const ( + testGUID = "001122334455" + testVersionID = "818970197" + ) + + var ( + ctrl *gomock.Controller + mockDownload *http.MockClient[downloadResult] + mockBagClient *http.MockClient[bagResult] + store *appstore + acc Account + app App + ) + + validUpdateItem := downloadItemResult{ + URL: "https://cdn/pinned.ipa", + Sinfs: []Sinf{{ID: 0, Data: []byte("sinf")}}, + Metadata: map[string]interface{}{ + "itemId": int64(568903335), + "softwareVersionExternalIdentifier": testVersionID, + "softwareVersionBundleId": "com.example.app", + }, + } + + BeforeEach(func() { + ctrl = gomock.NewController(GinkgoT()) + mockDownload = http.NewMockClient[downloadResult](ctrl) + mockBagClient = http.NewMockClient[bagResult](ctrl) + store = &appstore{ + downloadClient: mockDownload, + bagClient: mockBagClient, + } + acc = Account{StoreFront: "143441", DirectoryServicesID: "1234"} + app = App{ID: 568903335, BundleID: "com.example.app"} + }) + + AfterEach(func() { + ctrl.Finish() + }) + + expectEmptyPrimary := func(previous *gomock.Call) *gomock.Call { + call := mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, Data: downloadResult{}, + }, nil) + + if previous != nil { + call = call.After(previous) + } + + return call + } + + expectBag := func(previous *gomock.Call, updateEndpoint string) *gomock.Call { + call := mockBagClient.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{ + StatusCode: gohttp.StatusOK, + Data: bagResult{URLBag: urlBag{UpdateEndpoint: updateEndpoint}}, + }, nil) + + return call.After(previous) + } + + expectUpdateRequest := func(previous *gomock.Call, item downloadItemResult, itemErr error) *gomock.Call { + return mockDownload.EXPECT().Send(gomock.Any()).Do(func(req http.Request) { + Expect(req.URL).To(Equal("https://downloaddispatch.itunes.apple.com/up/updateProduct?guid=" + testGUID)) + + payload, ok := req.Payload.(*http.XMLPayload) + Expect(ok).To(BeTrue()) + Expect(payload.Content).To(HaveKeyWithValue("salableAdamId", int64(568903335))) + Expect(payload.Content).To(HaveKeyWithValue("appExtVrsId", testVersionID)) + }).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, Data: downloadResult{Items: []downloadItemResult{item}}, + }, itemErr).After(previous) + } + + // The fallback only kicks in after the primary endpoint came back empty + // twice (first try + retry). + expectEmptyPrimaries := func() *gomock.Call { + first := expectEmptyPrimary(nil) + + return expectEmptyPrimary(first) + } + + It("serves a pinned tvOS version via updateProduct when redownload returns an empty HTTP 500", func() { + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + previous = expectBag(previous, "https://downloaddispatch.itunes.apple.com/up/updateProduct") + expectUpdateRequest(previous, validUpdateItem, nil) + + item, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).ToNot(HaveOccurred()) + Expect(item.URL).To(Equal(validUpdateItem.URL)) + }) + + It("serves a pinned version when redownload reports it as no longer available", func() { + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{CustomerMessage: "No Longer Available"}, + }, nil).After(previous) + previous = expectBag(previous, "https://downloaddispatch.itunes.apple.com/up/updateProduct") + expectUpdateRequest(previous, validUpdateItem, nil) + + item, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).ToNot(HaveOccurred()) + Expect(item.URL).To(Equal(validUpdateItem.URL)) + }) + + It("preserves an unpinned tvOS availability response without updating", func() { + previous := expectEmptyPrimaries() + mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{CustomerMessage: "No Longer Available"}, + }, nil).After(previous) + + _, err := store.fetchDownloadItem(acc, app, testGUID, "", PlatformAppleTV) + Expect(err).To(MatchError(ContainSubstring("No Longer Available"))) + }) + + It("rejects an update response that does not match the requested version", func() { + mismatched := validUpdateItem + mismatched.Metadata = map[string]interface{}{ + "itemId": int64(568903335), + "softwareVersionExternalIdentifier": "999999999", + "softwareVersionBundleId": "com.example.app", + } + + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + previous = expectBag(previous, "https://downloaddispatch.itunes.apple.com/up/updateProduct") + expectUpdateRequest(previous, mismatched, nil) + + _, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).To(MatchError("update response does not match the requested app or version")) + }) + + It("rejects an update response with a wrong bundle identifier", func() { + mismatched := validUpdateItem + mismatched.Metadata = map[string]interface{}{ + "itemId": int64(568903335), + "softwareVersionExternalIdentifier": testVersionID, + "softwareVersionBundleId": "com.example.other", + } + + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + previous = expectBag(previous, "https://downloaddispatch.itunes.apple.com/up/updateProduct") + expectUpdateRequest(previous, mismatched, nil) + + _, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).To(MatchError("update response does not match the requested bundle identifier")) + }) + + It("rejects an update endpoint outside the download dispatch domain", func() { + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + previous = expectBag(previous, "https://evil.example.com/up/updateProduct") + + _, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).To(MatchError("invalid download endpoint in bag")) + }) + + It("propagates license errors from the update endpoint", func() { + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + previous = expectBag(previous, "https://downloaddispatch.itunes.apple.com/up/updateProduct") + mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{ + StatusCode: gohttp.StatusOK, + Data: downloadResult{FailureType: FailureTypeLicenseNotFound}, + }, nil).After(previous) + + _, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).To(MatchError(ErrLicenseRequired)) + }) + + It("keeps the redownload error when the bag has no update endpoint", func() { + previous := expectEmptyPrimaries() + previous = mockDownload.EXPECT().Send(gomock.Any()).Return(http.Result[downloadResult]{}, &http.UnexpectedResponseError{ + StatusCode: gohttp.StatusInternalServerError, + }).After(previous) + mockBagClient.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{ + StatusCode: gohttp.StatusOK, Data: bagResult{URLBag: urlBag{}}, + }, nil).After(previous) + + _, err := store.fetchDownloadItem(acc, app, testGUID, testVersionID, PlatformAppleTV) + Expect(err).To(MatchError(ContainSubstring("failed to send redownload request"))) + }) +}) From 78925bc1d58f94c649d256804860bebd5e8c552d Mon Sep 17 00:00:00 2001 From: pdx15 <301492+pdx15@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:49:24 +0000 Subject: [PATCH 17/18] fix: add missing strings import and joinCleanupError helper First CI run (unit tests) failed to compile with: - undefined: strings (appstore_search.go) - undefined: joinCleanupError x3 (appstore_download.go) The helper lives upstream in appstore_replicate_sinf.go; the fork's copy of that file predates it, so append the same implementation. Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_replicate_sinf.go | 9 +++++++++ pkg/appstore/appstore_search.go | 1 + 2 files changed, 10 insertions(+) diff --git a/pkg/appstore/appstore_replicate_sinf.go b/pkg/appstore/appstore_replicate_sinf.go index afa0376..0ec2f09 100644 --- a/pkg/appstore/appstore_replicate_sinf.go +++ b/pkg/appstore/appstore_replicate_sinf.go @@ -380,3 +380,12 @@ func (*appstore) readBundleName(reader *zip.ReadCloser) (string, error) { return bundleName, nil } + +func joinCleanupError(err error, message string, cleanupErr error) error { + wrapped := fmt.Errorf("%s: %w", message, cleanupErr) + if err == nil { + return wrapped + } + + return errors.Join(err, wrapped) +} diff --git a/pkg/appstore/appstore_search.go b/pkg/appstore/appstore_search.go index 3114536..408cfd5 100644 --- a/pkg/appstore/appstore_search.go +++ b/pkg/appstore/appstore_search.go @@ -8,6 +8,7 @@ import ( "net/url" "slices" "strconv" + "strings" "github.com/majd/ipatool/v2/pkg/http" ) From 92fad5230cd7d8820766a8f6ce9c8b271822abd7 Mon Sep 17 00:00:00 2001 From: pdx15 <301492+pdx15@users.noreply.github.com> Date: Wed, 30 Sep 2026 03:59:03 +0000 Subject: [PATCH 18/18] fix: repair test compile errors in appstore package - appstore_download_test: local mock helper 'redownload' shadowed the table's bool parameter, breaking declaration and calls; rename the helper to redownloadCall. - appstore_macos_version_lookup_test: the ported test called upstream's sendDownloadProduct method which the fork does not have; test the fork's lookupLatestMacOSExternalVersionID instead (same mocked storefront fetch, same failure entries). Co-authored-by: arena-agent <297053741+arena-agent@users.noreply.github.com> --- pkg/appstore/appstore_download_test.go | 6 +++--- pkg/appstore/appstore_macos_version_lookup_test.go | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/pkg/appstore/appstore_download_test.go b/pkg/appstore/appstore_download_test.go index 0b7af56..973a75b 100644 --- a/pkg/appstore/appstore_download_test.go +++ b/pkg/appstore/appstore_download_test.go @@ -838,7 +838,7 @@ var _ = Describe("Downloading delisted tvOS apps", func() { StatusCode: gohttp.StatusOK, Data: data, }, nil) } - redownload := func(data downloadResult) *gomock.Call { + redownloadCall := func(data downloadResult) *gomock.Call { return downloadClient.EXPECT().Send(gomock.Any()).Do(checkRequest).Return(http.Result[downloadResult]{ StatusCode: gohttp.StatusOK, Data: data, }, nil) @@ -847,11 +847,11 @@ var _ = Describe("Downloading delisted tvOS apps", func() { var calls []*gomock.Call if redownload { // Empty primary responses fall through to the redownload endpoint. - calls = append(calls, primary(downloadResult{}), primary(downloadResult{}), redownload(successData)) + calls = append(calls, primary(downloadResult{}), primary(downloadResult{}), redownloadCall(successData)) } else { calls = append(calls, primary(successData)) } - calls = append(calls, primary(successData), redownload(successData)) + calls = append(calls, primary(successData), redownloadCall(successData)) gomock.InOrder(append([]*gomock.Call{previous}, calls...)) httpClient.EXPECT().NewRequest("GET", "https://example.test/app.ipa", nil). diff --git a/pkg/appstore/appstore_macos_version_lookup_test.go b/pkg/appstore/appstore_macos_version_lookup_test.go index 3d8a2ec..5452e46 100644 --- a/pkg/appstore/appstore_macos_version_lookup_test.go +++ b/pkg/appstore/appstore_macos_version_lookup_test.go @@ -44,7 +44,7 @@ var _ = Describe("Mac purchase version selection", func() { pages := http.NewMockClient[[]byte](ctrl) store := &appstore{storefrontClient: pages, downloadClient: http.NewMockClient[downloadResult](ctrl)} pages.EXPECT().Send(gomock.Any()).Return(http.Result[[]byte]{StatusCode: status, Data: body}, requestErr) - _, _, err := store.sendDownloadProduct(Account{StoreFront: "143443-2,34"}, app, "001122334455", "", PlatformMacOS) + _, err := store.lookupLatestMacOSExternalVersionID(Account{StoreFront: "143443-2,34"}, app) Expect(err).To(HaveOccurred()) }, Entry("HTTP failure", 500, nil, nil),