diff --git a/pkg/appstore/appstore_get_version_metadata.go b/pkg/appstore/appstore_get_version_metadata.go index 3971df45..efa182be 100644 --- a/pkg/appstore/appstore_get_version_metadata.go +++ b/pkg/appstore/appstore_get_version_metadata.go @@ -57,15 +57,15 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion item := res.Data.Items[0] - releaseDate, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", item.Metadata["releaseDate"])) + // Do not fall back to item.Metadata here. The App Store download API can + // return stale version and release date values, so the IPA Info.plist is the + // source of truth and failures should be visible to callers. + metadata, err := t.readVersionMetadataFromIPA(item.URL) if err != nil { - return GetVersionMetadataOutput{}, fmt.Errorf("failed to parse release date: %w", err) + return GetVersionMetadataOutput{}, fmt.Errorf("failed to read version metadata: %w", err) } - return GetVersionMetadataOutput{ - DisplayVersion: fmt.Sprintf("%v", item.Metadata["bundleShortVersionString"]), - ReleaseDate: releaseDate, - }, nil + return GetVersionMetadataOutput(metadata), nil } func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request { diff --git a/pkg/appstore/appstore_get_version_metadata_test.go b/pkg/appstore/appstore_get_version_metadata_test.go index 3324b899..8c0a72a8 100644 --- a/pkg/appstore/appstore_get_version_metadata_test.go +++ b/pkg/appstore/appstore_get_version_metadata_test.go @@ -1,7 +1,16 @@ package appstore import ( + "archive/zip" + "bytes" "errors" + "fmt" + "io" + gohttp "net/http" + "net/http/httptest" + "strconv" + "strings" + "sync/atomic" "time" "github.com/majd/ipatool/v2/pkg/http" @@ -9,8 +18,155 @@ import ( . "github.com/onsi/ginkgo/v2" . "github.com/onsi/gomega" "go.uber.org/mock/gomock" + "howett.net/plist" ) +func testIPA(displayVersion string, releaseDate interface{}, modified time.Time) []byte { + buffer := new(bytes.Buffer) + zipWriter := zip.NewWriter(buffer) + + fillerHeader := &zip.FileHeader{ + Name: "Payload/Test.app/Filler.bin", + Method: zip.Store, + } + filler, err := zipWriter.CreateHeader(fillerHeader) + Expect(err).ToNot(HaveOccurred()) + + _, err = filler.Write(make([]byte, 1024*1024)) + Expect(err).ToNot(HaveOccurred()) + + infoHeader := &zip.FileHeader{ + Name: "Payload/Test.app/Info.plist", + Method: zip.Deflate, + Modified: modified, + } + + infoFile, err := zipWriter.CreateHeader(infoHeader) + Expect(err).ToNot(HaveOccurred()) + + info := map[string]interface{}{ + "CFBundleExecutable": "Test", + "CFBundleShortVersionString": displayVersion, + } + if releaseDate != nil { + info["releaseDate"] = releaseDate + } + + infoData, err := plist.Marshal(info, plist.BinaryFormat) + Expect(err).ToNot(HaveOccurred()) + + _, err = infoFile.Write(infoData) + Expect(err).ToNot(HaveOccurred()) + + err = zipWriter.Close() + Expect(err).ToNot(HaveOccurred()) + + return buffer.Bytes() +} + +func testIPAServer(data []byte) (*httptest.Server, *int64, *int64) { + return testIPAServerWithRangeLog(data, nil) +} + +func testIPAServerWithRangeLog(data []byte, rangeLog *[]string) (*httptest.Server, *int64, *int64) { + var ( + servedBytes int64 + wholeGetCount int64 + ) + + server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + if r.Method != gohttp.MethodGet { + w.WriteHeader(gohttp.StatusMethodNotAllowed) + + return + } + + rangeHeader := r.Header.Get("Range") + if rangeLog != nil { + *rangeLog = append(*rangeLog, rangeHeader) + } + + if rangeHeader == "" { + atomic.AddInt64(&wholeGetCount, 1) + w.WriteHeader(gohttp.StatusOK) + _, _ = w.Write(data) + + return + } + + start, end, err := testRangeBounds(rangeHeader, len(data)) + if err != nil { + w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable) + + return + } + + w.Header().Set("Accept-Ranges", "bytes") + w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data))) + w.Header().Set("Content-Length", strconv.Itoa(end-start+1)) + w.WriteHeader(gohttp.StatusPartialContent) + + n, _ := w.Write(data[start : end+1]) + atomic.AddInt64(&servedBytes, int64(n)) + })) + + return server, &servedBytes, &wholeGetCount +} + +func testRangeBounds(header string, size int) (int, int, error) { + if !strings.HasPrefix(header, "bytes=") { + return 0, 0, fmt.Errorf("invalid range header: %s", header) + } + + parts := strings.Split(strings.TrimPrefix(header, "bytes="), "-") + if len(parts) != 2 { + return 0, 0, fmt.Errorf("invalid range header: %s", header) + } + + start, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse range start: %w", err) + } + + end := size - 1 + if parts[1] != "" { + end, err = strconv.Atoi(parts[1]) + if err != nil { + return 0, 0, fmt.Errorf("failed to parse range end: %w", err) + } + } + + if start < 0 || start >= size || end < start { + return 0, 0, fmt.Errorf("invalid range bounds: %s", header) + } + + if end >= size { + end = size - 1 + } + + return start, end, nil +} + +var _ = Describe("HTTPRangeReaderAt", func() { + It("clamps reads that cross EOF", func() { + data := []byte("abcdef") + rangeLog := []string{} + server, _, _ := testIPAServerWithRangeLog(data, &rangeLog) + defer server.Close() + + reader, size, err := newHTTPRangeReaderAt(http.NewClient[interface{}](http.Args{}), server.URL) + Expect(err).NotTo(HaveOccurred()) + Expect(size).To(Equal(int64(len(data)))) + + buf := make([]byte, 4) + n, err := reader.ReadAt(buf, 4) + Expect(n).To(Equal(2)) + Expect(err).To(Equal(io.EOF)) + Expect(string(buf[:n])).To(Equal("ef")) + Expect(rangeLog).To(ContainElement("bytes=4-5")) + }) +}) + var _ = Describe("AppStore (GetVersionMetadata)", func() { var ( ctrl *gomock.Controller @@ -26,6 +182,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { as = &appstore{ machine: mockMachine, downloadClient: mockDownloadClient, + httpClient: http.NewClient[interface{}](http.Args{}), } }) @@ -227,7 +384,12 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) When("fails to parse release date", func() { + var server *httptest.Server + BeforeEach(func() { + ipa := testIPA("1.0.0", "invalid-date", time.Date(2024, 3, 19, 12, 0, 0, 0, time.UTC)) + server, _, _ = testIPAServer(ipa) + mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) @@ -238,8 +400,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { Data: downloadResult{ Items: []downloadItemResult{ { + URL: server.URL, Metadata: map[string]interface{}{ - "releaseDate": "invalid-date", + "bundleShortVersionString": "1.0.0", + "releaseDate": "invalid-date", }, }, }, @@ -247,6 +411,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }, nil) }) + AfterEach(func() { + server.Close() + }) + It("returns error", func() { _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) Expect(err).To(HaveOccurred()) @@ -254,8 +422,14 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) }) - When("successfully gets version metadata", func() { + When("IPA metadata cannot be read", func() { + var server *httptest.Server + BeforeEach(func() { + server = httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) { + w.WriteHeader(gohttp.StatusOK) + })) + mockMachine.EXPECT(). MacAddress(). Return("00:11:22:33:44:55", nil) @@ -266,8 +440,57 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { Data: downloadResult{ Items: []downloadItemResult{ { + URL: server.URL, Metadata: map[string]interface{}{ + "bundleShortVersionString": "1.0.0", "releaseDate": "2024-03-20T12:00:00Z", + }, + }, + }, + }, + }, nil) + }) + + AfterEach(func() { + server.Close() + }) + + It("returns error instead of falling back to API metadata", func() { + _, err := as.GetVersionMetadata(GetVersionMetadataInput{}) + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("failed to read version metadata")) + }) + }) + + When("successfully gets version metadata", func() { + var ( + server *httptest.Server + ipa []byte + servedBytes *int64 + wholeGetCount *int64 + releaseDate time.Time + displayVersion string + ) + + BeforeEach(func() { + releaseDate = time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC) + displayVersion = "2.0.0" + ipa = testIPA(displayVersion, fmt.Sprintf(" \n%s\t", releaseDate.Format(time.RFC3339)), time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC)) + server, servedBytes, wholeGetCount = testIPAServer(ipa) + + mockMachine.EXPECT(). + MacAddress(). + Return("00:11:22:33:44:55", nil) + + mockDownloadClient.EXPECT(). + Send(gomock.Any()). + Return(http.Result[downloadResult]{ + Data: downloadResult{ + Items: []downloadItemResult{ + { + URL: server.URL, + Metadata: map[string]interface{}{ + "releaseDate": "2020-01-01T00:00:00Z", "bundleShortVersionString": "1.0.0", }, }, @@ -276,6 +499,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }, nil) }) + AfterEach(func() { + server.Close() + }) + It("returns version metadata", func() { output, err := as.GetVersionMetadata(GetVersionMetadataInput{ Account: Account{ @@ -288,8 +515,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() { }) Expect(err).NotTo(HaveOccurred()) - Expect(output.DisplayVersion).To(Equal("1.0.0")) - Expect(output.ReleaseDate).To(Equal(time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC))) + Expect(output.DisplayVersion).To(Equal(displayVersion)) + Expect(output.ReleaseDate).To(Equal(releaseDate)) + Expect(atomic.LoadInt64(wholeGetCount)).To(BeZero()) + Expect(atomic.LoadInt64(servedBytes)).To(BeNumerically("<", int64(len(ipa)/2))) }) }) }) diff --git a/pkg/appstore/appstore_partial_zip.go b/pkg/appstore/appstore_partial_zip.go new file mode 100644 index 00000000..d824c434 --- /dev/null +++ b/pkg/appstore/appstore_partial_zip.go @@ -0,0 +1,318 @@ +package appstore + +import ( + "archive/zip" + "bytes" + "errors" + "fmt" + "io" + "math" + "net/http" + "strconv" + "strings" + "time" + + apphttp "github.com/majd/ipatool/v2/pkg/http" + "howett.net/plist" +) + +var infoPlistReleaseDateKeys = []string{ + "releaseDate", + "ReleaseDate", +} + +var infoPlistDisplayVersionKeys = []string{ + "CFBundleShortVersionString", + "bundleShortVersionString", +} + +type versionMetadata struct { + DisplayVersion string + ReleaseDate time.Time +} + +type httpRangeReaderAt struct { + client apphttp.Client[interface{}] + url string + size int64 +} + +func newHTTPRangeReaderAt(client apphttp.Client[interface{}], url string) (*httpRangeReaderAt, int64, error) { + if url == "" { + return nil, 0, errors.New("url is empty") + } + + size, err := remoteFileSize(client, url) + if err != nil { + return nil, 0, fmt.Errorf("failed to read remote file size: %w", err) + } + + return &httpRangeReaderAt{ + client: client, + url: url, + size: size, + }, size, nil +} + +func remoteFileSize(client apphttp.Client[interface{}], url string) (int64, error) { + req, err := client.NewRequest("GET", url, nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Range", "bytes=0-0") + + res, err := client.Do(req) + if err != nil { + return 0, fmt.Errorf("request failed: %w", err) + } + + if res != nil && res.Body != nil { + defer res.Body.Close() + } + + if res.StatusCode != http.StatusPartialContent { + return 0, fmt.Errorf("expected partial content response, got status %d", res.StatusCode) + } + + size, err := parseContentRangeSize(res.Header.Get("Content-Range")) + if err != nil { + return 0, err + } + + return size, nil +} + +func parseContentRangeSize(header string) (int64, error) { + if header == "" { + return 0, errors.New("content range is empty") + } + + slash := strings.LastIndex(header, "/") + if slash == -1 || slash == len(header)-1 { + return 0, fmt.Errorf("invalid content range: %s", header) + } + + sizeText := header[slash+1:] + if sizeText == "*" { + return 0, fmt.Errorf("invalid content range size: %s", sizeText) + } + + size, err := strconv.ParseInt(sizeText, 10, 64) + if err != nil { + return 0, fmt.Errorf("failed to parse content range size: %w", err) + } + + return size, nil +} + +func (r *httpRangeReaderAt) ReadAt(p []byte, off int64) (int, error) { + if len(p) == 0 { + return 0, nil + } + + if off < 0 { + return 0, errors.New("offset is negative") + } + + if off >= r.size { + return 0, io.EOF + } + + requestEnd := off + int64(len(p)) - 1 + rangeEnd := requestEnd + + if rangeEnd >= r.size { + rangeEnd = r.size - 1 + } + + req, err := r.client.NewRequest("GET", r.url, nil) + if err != nil { + return 0, fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Accept-Encoding", "identity") + req.Header.Set("Range", fmt.Sprintf("bytes=%d-%d", off, rangeEnd)) + + res, err := r.client.Do(req) + if err != nil { + return 0, fmt.Errorf("request failed: %w", err) + } + + if res != nil && res.Body != nil { + defer res.Body.Close() + } + + if res.StatusCode != http.StatusPartialContent { + return 0, fmt.Errorf("expected partial content response, got status %d", res.StatusCode) + } + + readLength := int(rangeEnd-off) + 1 + + n, err := io.ReadFull(res.Body, p[:readLength]) + if err == io.ErrUnexpectedEOF || err == io.EOF { + return n, io.EOF + } + + if err != nil { + return n, fmt.Errorf("failed to read response body: %w", err) + } + + if requestEnd >= r.size { + return n, io.EOF + } + + return n, nil +} + +func (t *appstore) readVersionMetadataFromIPA(url string) (versionMetadata, error) { + reader, size, err := newHTTPRangeReaderAt(t.httpClient, url) + if err != nil { + return versionMetadata{}, err + } + + zipReader, err := zip.NewReader(reader, size) + if err != nil { + return versionMetadata{}, fmt.Errorf("failed to open zip reader: %w", err) + } + + for _, file := range zipReader.File { + if !isMainAppInfoPlist(file.Name) { + continue + } + + metadata, err := readVersionMetadataFromInfoPlist(file) + if err != nil { + return versionMetadata{}, err + } + + return metadata, nil + } + + return versionMetadata{}, errors.New("could not find Info.plist") +} + +func isMainAppInfoPlist(name string) bool { + parts := strings.Split(name, "/") + + return len(parts) == 3 && parts[0] == "Payload" && strings.HasSuffix(parts[1], ".app") && parts[2] == "Info.plist" +} + +func readVersionMetadataFromInfoPlist(file *zip.File) (versionMetadata, error) { + src, err := file.Open() + if err != nil { + return versionMetadata{}, fmt.Errorf("failed to open Info.plist: %w", err) + } + defer src.Close() + + data := new(bytes.Buffer) + + _, err = io.Copy(data, src) + if err != nil { + return versionMetadata{}, fmt.Errorf("failed to read Info.plist: %w", err) + } + + metadata := map[string]interface{}{} + + _, err = plist.Unmarshal(data.Bytes(), &metadata) + if err != nil { + return versionMetadata{}, fmt.Errorf("failed to unmarshal Info.plist: %w", err) + } + + displayVersion, err := readDisplayVersionFromMetadata(metadata) + if err != nil { + return versionMetadata{}, err + } + + releaseDate, err := readReleaseDateFromInfoPlist(metadata, file.Modified) + if err != nil { + return versionMetadata{}, err + } + + return versionMetadata{ + DisplayVersion: displayVersion, + ReleaseDate: releaseDate, + }, nil +} + +func readDisplayVersionFromMetadata(metadata map[string]interface{}) (string, error) { + for _, key := range infoPlistDisplayVersionKeys { + value, ok := metadata[key] + if !ok { + continue + } + + displayVersion := strings.TrimSpace(fmt.Sprintf("%v", value)) + if displayVersion == "" || displayVersion == "" { + return "", fmt.Errorf("%s is empty", key) + } + + return displayVersion, nil + } + + return "", errors.New("info plist does not contain a display version") +} + +func readReleaseDateFromInfoPlist(metadata map[string]interface{}, modified time.Time) (time.Time, error) { + for _, key := range infoPlistReleaseDateKeys { + value, ok := metadata[key] + if !ok { + continue + } + + releaseDate, err := parseReleaseDateValue(value) + if err != nil { + return time.Time{}, fmt.Errorf("failed to parse release date: %w", err) + } + + return releaseDate, nil + } + + if modified.IsZero() { + return time.Time{}, errors.New("info plist does not contain a release date") + } + + return modified.UTC(), nil +} + +func parseReleaseDateValue(value interface{}) (time.Time, error) { + switch val := value.(type) { + case time.Time: + return val.UTC(), nil + case string: + return parseReleaseDateString(val) + case int: + return time.Unix(int64(val), 0).UTC(), nil + case int64: + return time.Unix(val, 0).UTC(), nil + case uint64: + if val > math.MaxInt64 { + return time.Time{}, fmt.Errorf("timestamp is too large: %d", val) + } + + return time.Unix(int64(val), 0).UTC(), nil + case float64: + return time.Unix(int64(val), 0).UTC(), nil + default: + return time.Time{}, fmt.Errorf("unsupported release date type %T", value) + } +} + +func parseReleaseDateString(value string) (time.Time, error) { + value = strings.TrimSpace(value) + + for _, layout := range []string{ + time.RFC3339, + time.RFC3339Nano, + "Monday, January 2, 2006", + "2006-01-02", + } { + parsed, err := time.Parse(layout, value) + if err == nil { + return parsed.UTC(), nil + } + } + + return time.Time{}, fmt.Errorf("invalid release date: %s", value) +}