Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
45 changes: 41 additions & 4 deletions pkg/appstore/appstore_download_product.go
Original file line number Diff line number Diff line change
Expand Up @@ -99,12 +99,20 @@ func (t *appstore) sendDownloadProduct(ctx context.Context, acc Account, app App

if externalVersionID == "" && (platform == "" || platform == PlatformIPhone || platform == PlatformIPad) {
// Unpinned redownloads can fail or return a tvOS package. Select
// the current iOS build before sending.
// the current iOS build when the catalog has an offer.
if platform == "" {
platform = PlatformIPhone
}

externalVersionID, err = t.lookupLatestExternalVersionID(acc, app, platform)
if errors.Is(err, errIOSCatalogAppNotFound) {
// A delisted app can still be available to this account. Try once
// without a version; Download validates the returned package's platform.
res, err := t.sendUnpinnedRedownload(ctx, redownload, acc, app, guid)

return res, platform, err
}

if err != nil {
return res, platform, fmt.Errorf("failed to resolve latest iOS version for redownload: %w", err)
}
Expand All @@ -130,6 +138,33 @@ func (t *appstore) sendDownloadProduct(ctx context.Context, acc Account, app App
return redownloadRes, platform, nil
}

func (t *appstore) sendUnpinnedRedownload(ctx context.Context, endpoint downloadProductEndpoint, acc Account, app App, guid string) (http.Result[downloadResult], error) {
requestContext, cancel := context.WithTimeout(ctx, http.DefaultAuthenticationTimeout)
defer cancel()

if err := requestContext.Err(); err != nil {
return http.Result[downloadResult]{}, fmt.Errorf("redownload request canceled: %w", err)
}

request := t.downloadProductRequest(endpoint, acc, app, guid, "")
request.Context = requestContext

res, err := t.downloadClient.Send(request)
if err != nil {
return res, fmt.Errorf("failed to send redownload request: %w", err)
}

if err := validateDownloadResponse(res, app, "", "redownload"); err != nil {
return res, err
}

if res.Data.FailureType == "" && res.Data.Items[0].URL == "" {
return res, errors.New("redownload response has no package URL")
}

return res, nil
}

func isEmptyRedownloadError(err error) bool {
var unexpected *http.UnexpectedResponseError

Expand All @@ -151,10 +186,12 @@ func (t *appstore) sendUpdateProduct(endpoint string, acc Account, app App, guid
return res, fmt.Errorf("failed to send update request: %w", err)
}

return res, validateVersionedDownloadResponse(res, app, externalVersionID, "update")
return res, validateDownloadResponse(res, app, externalVersionID, "update")
}

func validateVersionedDownloadResponse(res http.Result[downloadResult], app App, externalVersionID, source string) error {
// An empty externalVersionID allows the server to select a version while retaining
// the app identity checks. Explicit and catalog-selected versions must match.
func validateDownloadResponse(res http.Result[downloadResult], app App, externalVersionID, source string) error {
if res.Data.FailureType != "" {
return nil
}
Expand All @@ -173,7 +210,7 @@ func validateVersionedDownloadResponse(res http.Result[downloadResult], app App,

metadata := res.Data.Items[0].Metadata
if fmt.Sprint(metadata["itemId"]) != fmt.Sprint(app.ID) ||
fmt.Sprint(metadata["softwareVersionExternalIdentifier"]) != externalVersionID {
(externalVersionID != "" && fmt.Sprint(metadata["softwareVersionExternalIdentifier"]) != externalVersionID) {
return fmt.Errorf("%s response does not match the requested app or version", source)
}

Expand Down
2 changes: 1 addition & 1 deletion pkg/appstore/appstore_download_product_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -365,7 +365,7 @@ var _ = Describe("AppStore (Download Product)", func() {
Expect(err).To(MatchError(ContainSubstring("failed to resolve latest iOS version for redownload")))
})

It("does not send an unpinned request when the app is absent from the catalog", func() {
It("does not send an unpinned request when the public catalog response is empty", func() {
mockPlatformClient.EXPECT().Send(gomock.Any()).
Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil).Times(4)

Expand Down
7 changes: 7 additions & 0 deletions pkg/appstore/appstore_ios_version_lookup.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,9 @@ import (
"github.com/majd/ipatool/v2/pkg/http"
)

// Only a catalog 404 confirms absence; empty or malformed metadata is an error.
var errIOSCatalogAppNotFound = errors.New("iOS catalog lookup returned HTTP 404")

type catalogVersionLookupItem struct {
ID string `json:"id,omitempty"`
Type string `json:"type,omitempty"`
Expand All @@ -36,6 +39,10 @@ func (t *appstore) lookupLatestIOSExternalVersionID(app App, countryCode string,
return "", fmt.Errorf("iOS catalog lookup request failed: %w", err)
}

if res.StatusCode == gohttp.StatusNotFound {
return "", errIOSCatalogAppNotFound
}

if res.StatusCode != gohttp.StatusOK {
return "", fmt.Errorf("iOS catalog lookup returned HTTP %d", res.StatusCode)
}
Expand Down
2 changes: 1 addition & 1 deletion pkg/appstore/appstore_kbsync.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,7 @@ func (t *appstore) sendEntDownloadRequest(ctx context.Context, endpoint download
return res, NewErrorWithMetadata(fmt.Errorf("received ent/download error: %s", res.Data.FailureType), res)
}

if err := validateVersionedDownloadResponse(res, app, versionID, "ent/download"); err != nil {
if err := validateDownloadResponse(res, app, versionID, "ent/download"); err != nil {
return res, err
}

Expand Down
25 changes: 25 additions & 0 deletions pkg/appstore/appstore_kbsync_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -376,6 +376,31 @@ var _ = Describe("AppStore (preferred kbsync download)", func() {
Expect(generated).To(BeZero())
})

It("reaches unpinned redownload after the preferred catalog lookup and volumeStore miss", func() {
bag.URLBag.RedownloadEndpoint = testRedownloadEndpoint
bag.URLBag.UpdateEndpoint = testUpdateEndpoint
gomock.InOrder(
bags.EXPECT().Send(gomock.Any()).Return(http.Result[bagResult]{StatusCode: gohttp.StatusOK, Data: bag}, nil),
platforms.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil).Times(3),
platforms.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusNotFound}, nil),
downloads.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
Expect(req.URL).To(ContainSubstring("volumeStoreDownloadProduct"))
Expect(req.Payload.(*http.XMLPayload).Content).NotTo(HaveKey("externalVersionId"))
}).Return(unavailable, nil),
platforms.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusOK}, nil).Times(3),
platforms.EXPECT().Send(gomock.Any()).Return(http.Result[platformVersionLookupResult]{StatusCode: gohttp.StatusNotFound}, nil),
downloads.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
Expect(req.URL).To(Equal(testRedownloadEndpoint + "?guid=" + guid))
Expect(req.Payload.(*http.XMLPayload).Content).NotTo(HaveKey("appExtVrsId"))
}).Return(success, nil),
)
actual, platform, err := store.sendDownloadProduct(context.Background(), account, app, guid, "", "")
Expect(err).NotTo(HaveOccurred())
Expect(actual).To(Equal(success))
Expect(platform).To(Equal(PlatformIPhone))
Expect(generated).To(BeZero())
})

It("does not send credentials to an invalid preferred endpoint and falls back", func() {
bag.URLBag.EntDownloadEndpoint = "https://example.com/WebObjects/DownloadDispatch.woa/wa/ent/download"
gomock.InOrder(
Expand Down
Loading
Loading