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
12 changes: 6 additions & 6 deletions pkg/appstore/appstore_get_version_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
237 changes: 233 additions & 4 deletions pkg/appstore/appstore_get_version_metadata_test.go
Original file line number Diff line number Diff line change
@@ -1,16 +1,172 @@
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"
"github.com/majd/ipatool/v2/pkg/util/machine"
. "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
Expand All @@ -26,6 +182,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
as = &appstore{
machine: mockMachine,
downloadClient: mockDownloadClient,
httpClient: http.NewClient[interface{}](http.Args{}),
}
})

Expand Down Expand Up @@ -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)
Expand All @@ -238,24 +400,36 @@ 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",
},
},
},
},
}, nil)
})

AfterEach(func() {
server.Close()
})

It("returns error", func() {
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
Expect(err).To(HaveOccurred())
Expect(err.Error()).To(ContainSubstring("failed to parse release date"))
})
})

When("successfully gets version metadata", func() {
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)
Expand All @@ -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",
},
},
Expand All @@ -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{
Expand All @@ -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)))
})
})
})
Loading
Loading