Skip to content

Commit abaecce

Browse files
authored
feat: perform partial zip reads for extracting version and release date (#472)
1 parent 8c4e037 commit abaecce

3 files changed

Lines changed: 557 additions & 10 deletions

File tree

pkg/appstore/appstore_get_version_metadata.go

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -57,15 +57,15 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion
5757

5858
item := res.Data.Items[0]
5959

60-
releaseDate, err := time.Parse(time.RFC3339, fmt.Sprintf("%v", item.Metadata["releaseDate"]))
60+
// Do not fall back to item.Metadata here. The App Store download API can
61+
// return stale version and release date values, so the IPA Info.plist is the
62+
// source of truth and failures should be visible to callers.
63+
metadata, err := t.readVersionMetadataFromIPA(item.URL)
6164
if err != nil {
62-
return GetVersionMetadataOutput{}, fmt.Errorf("failed to parse release date: %w", err)
65+
return GetVersionMetadataOutput{}, fmt.Errorf("failed to read version metadata: %w", err)
6366
}
6467

65-
return GetVersionMetadataOutput{
66-
DisplayVersion: fmt.Sprintf("%v", item.Metadata["bundleShortVersionString"]),
67-
ReleaseDate: releaseDate,
68-
}, nil
68+
return GetVersionMetadataOutput(metadata), nil
6969
}
7070

7171
func (t *appstore) getVersionMetadataRequest(acc Account, app App, guid string, version string) http.Request {

pkg/appstore/appstore_get_version_metadata_test.go

Lines changed: 233 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,172 @@
11
package appstore
22

33
import (
4+
"archive/zip"
5+
"bytes"
46
"errors"
7+
"fmt"
8+
"io"
9+
gohttp "net/http"
10+
"net/http/httptest"
11+
"strconv"
12+
"strings"
13+
"sync/atomic"
514
"time"
615

716
"github.com/majd/ipatool/v2/pkg/http"
817
"github.com/majd/ipatool/v2/pkg/util/machine"
918
. "github.com/onsi/ginkgo/v2"
1019
. "github.com/onsi/gomega"
1120
"go.uber.org/mock/gomock"
21+
"howett.net/plist"
1222
)
1323

24+
func testIPA(displayVersion string, releaseDate interface{}, modified time.Time) []byte {
25+
buffer := new(bytes.Buffer)
26+
zipWriter := zip.NewWriter(buffer)
27+
28+
fillerHeader := &zip.FileHeader{
29+
Name: "Payload/Test.app/Filler.bin",
30+
Method: zip.Store,
31+
}
32+
filler, err := zipWriter.CreateHeader(fillerHeader)
33+
Expect(err).ToNot(HaveOccurred())
34+
35+
_, err = filler.Write(make([]byte, 1024*1024))
36+
Expect(err).ToNot(HaveOccurred())
37+
38+
infoHeader := &zip.FileHeader{
39+
Name: "Payload/Test.app/Info.plist",
40+
Method: zip.Deflate,
41+
Modified: modified,
42+
}
43+
44+
infoFile, err := zipWriter.CreateHeader(infoHeader)
45+
Expect(err).ToNot(HaveOccurred())
46+
47+
info := map[string]interface{}{
48+
"CFBundleExecutable": "Test",
49+
"CFBundleShortVersionString": displayVersion,
50+
}
51+
if releaseDate != nil {
52+
info["releaseDate"] = releaseDate
53+
}
54+
55+
infoData, err := plist.Marshal(info, plist.BinaryFormat)
56+
Expect(err).ToNot(HaveOccurred())
57+
58+
_, err = infoFile.Write(infoData)
59+
Expect(err).ToNot(HaveOccurred())
60+
61+
err = zipWriter.Close()
62+
Expect(err).ToNot(HaveOccurred())
63+
64+
return buffer.Bytes()
65+
}
66+
67+
func testIPAServer(data []byte) (*httptest.Server, *int64, *int64) {
68+
return testIPAServerWithRangeLog(data, nil)
69+
}
70+
71+
func testIPAServerWithRangeLog(data []byte, rangeLog *[]string) (*httptest.Server, *int64, *int64) {
72+
var (
73+
servedBytes int64
74+
wholeGetCount int64
75+
)
76+
77+
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
78+
if r.Method != gohttp.MethodGet {
79+
w.WriteHeader(gohttp.StatusMethodNotAllowed)
80+
81+
return
82+
}
83+
84+
rangeHeader := r.Header.Get("Range")
85+
if rangeLog != nil {
86+
*rangeLog = append(*rangeLog, rangeHeader)
87+
}
88+
89+
if rangeHeader == "" {
90+
atomic.AddInt64(&wholeGetCount, 1)
91+
w.WriteHeader(gohttp.StatusOK)
92+
_, _ = w.Write(data)
93+
94+
return
95+
}
96+
97+
start, end, err := testRangeBounds(rangeHeader, len(data))
98+
if err != nil {
99+
w.WriteHeader(gohttp.StatusRequestedRangeNotSatisfiable)
100+
101+
return
102+
}
103+
104+
w.Header().Set("Accept-Ranges", "bytes")
105+
w.Header().Set("Content-Range", fmt.Sprintf("bytes %d-%d/%d", start, end, len(data)))
106+
w.Header().Set("Content-Length", strconv.Itoa(end-start+1))
107+
w.WriteHeader(gohttp.StatusPartialContent)
108+
109+
n, _ := w.Write(data[start : end+1])
110+
atomic.AddInt64(&servedBytes, int64(n))
111+
}))
112+
113+
return server, &servedBytes, &wholeGetCount
114+
}
115+
116+
func testRangeBounds(header string, size int) (int, int, error) {
117+
if !strings.HasPrefix(header, "bytes=") {
118+
return 0, 0, fmt.Errorf("invalid range header: %s", header)
119+
}
120+
121+
parts := strings.Split(strings.TrimPrefix(header, "bytes="), "-")
122+
if len(parts) != 2 {
123+
return 0, 0, fmt.Errorf("invalid range header: %s", header)
124+
}
125+
126+
start, err := strconv.Atoi(parts[0])
127+
if err != nil {
128+
return 0, 0, fmt.Errorf("failed to parse range start: %w", err)
129+
}
130+
131+
end := size - 1
132+
if parts[1] != "" {
133+
end, err = strconv.Atoi(parts[1])
134+
if err != nil {
135+
return 0, 0, fmt.Errorf("failed to parse range end: %w", err)
136+
}
137+
}
138+
139+
if start < 0 || start >= size || end < start {
140+
return 0, 0, fmt.Errorf("invalid range bounds: %s", header)
141+
}
142+
143+
if end >= size {
144+
end = size - 1
145+
}
146+
147+
return start, end, nil
148+
}
149+
150+
var _ = Describe("HTTPRangeReaderAt", func() {
151+
It("clamps reads that cross EOF", func() {
152+
data := []byte("abcdef")
153+
rangeLog := []string{}
154+
server, _, _ := testIPAServerWithRangeLog(data, &rangeLog)
155+
defer server.Close()
156+
157+
reader, size, err := newHTTPRangeReaderAt(http.NewClient[interface{}](http.Args{}), server.URL)
158+
Expect(err).NotTo(HaveOccurred())
159+
Expect(size).To(Equal(int64(len(data))))
160+
161+
buf := make([]byte, 4)
162+
n, err := reader.ReadAt(buf, 4)
163+
Expect(n).To(Equal(2))
164+
Expect(err).To(Equal(io.EOF))
165+
Expect(string(buf[:n])).To(Equal("ef"))
166+
Expect(rangeLog).To(ContainElement("bytes=4-5"))
167+
})
168+
})
169+
14170
var _ = Describe("AppStore (GetVersionMetadata)", func() {
15171
var (
16172
ctrl *gomock.Controller
@@ -26,6 +182,7 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
26182
as = &appstore{
27183
machine: mockMachine,
28184
downloadClient: mockDownloadClient,
185+
httpClient: http.NewClient[interface{}](http.Args{}),
29186
}
30187
})
31188

@@ -227,7 +384,12 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
227384
})
228385

229386
When("fails to parse release date", func() {
387+
var server *httptest.Server
388+
230389
BeforeEach(func() {
390+
ipa := testIPA("1.0.0", "invalid-date", time.Date(2024, 3, 19, 12, 0, 0, 0, time.UTC))
391+
server, _, _ = testIPAServer(ipa)
392+
231393
mockMachine.EXPECT().
232394
MacAddress().
233395
Return("00:11:22:33:44:55", nil)
@@ -238,24 +400,36 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
238400
Data: downloadResult{
239401
Items: []downloadItemResult{
240402
{
403+
URL: server.URL,
241404
Metadata: map[string]interface{}{
242-
"releaseDate": "invalid-date",
405+
"bundleShortVersionString": "1.0.0",
406+
"releaseDate": "invalid-date",
243407
},
244408
},
245409
},
246410
},
247411
}, nil)
248412
})
249413

414+
AfterEach(func() {
415+
server.Close()
416+
})
417+
250418
It("returns error", func() {
251419
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
252420
Expect(err).To(HaveOccurred())
253421
Expect(err.Error()).To(ContainSubstring("failed to parse release date"))
254422
})
255423
})
256424

257-
When("successfully gets version metadata", func() {
425+
When("IPA metadata cannot be read", func() {
426+
var server *httptest.Server
427+
258428
BeforeEach(func() {
429+
server = httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
430+
w.WriteHeader(gohttp.StatusOK)
431+
}))
432+
259433
mockMachine.EXPECT().
260434
MacAddress().
261435
Return("00:11:22:33:44:55", nil)
@@ -266,8 +440,57 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
266440
Data: downloadResult{
267441
Items: []downloadItemResult{
268442
{
443+
URL: server.URL,
269444
Metadata: map[string]interface{}{
445+
"bundleShortVersionString": "1.0.0",
270446
"releaseDate": "2024-03-20T12:00:00Z",
447+
},
448+
},
449+
},
450+
},
451+
}, nil)
452+
})
453+
454+
AfterEach(func() {
455+
server.Close()
456+
})
457+
458+
It("returns error instead of falling back to API metadata", func() {
459+
_, err := as.GetVersionMetadata(GetVersionMetadataInput{})
460+
Expect(err).To(HaveOccurred())
461+
Expect(err.Error()).To(ContainSubstring("failed to read version metadata"))
462+
})
463+
})
464+
465+
When("successfully gets version metadata", func() {
466+
var (
467+
server *httptest.Server
468+
ipa []byte
469+
servedBytes *int64
470+
wholeGetCount *int64
471+
releaseDate time.Time
472+
displayVersion string
473+
)
474+
475+
BeforeEach(func() {
476+
releaseDate = time.Date(2024, 4, 2, 12, 0, 0, 0, time.UTC)
477+
displayVersion = "2.0.0"
478+
ipa = testIPA(displayVersion, fmt.Sprintf(" \n%s\t", releaseDate.Format(time.RFC3339)), time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC))
479+
server, servedBytes, wholeGetCount = testIPAServer(ipa)
480+
481+
mockMachine.EXPECT().
482+
MacAddress().
483+
Return("00:11:22:33:44:55", nil)
484+
485+
mockDownloadClient.EXPECT().
486+
Send(gomock.Any()).
487+
Return(http.Result[downloadResult]{
488+
Data: downloadResult{
489+
Items: []downloadItemResult{
490+
{
491+
URL: server.URL,
492+
Metadata: map[string]interface{}{
493+
"releaseDate": "2020-01-01T00:00:00Z",
271494
"bundleShortVersionString": "1.0.0",
272495
},
273496
},
@@ -276,6 +499,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
276499
}, nil)
277500
})
278501

502+
AfterEach(func() {
503+
server.Close()
504+
})
505+
279506
It("returns version metadata", func() {
280507
output, err := as.GetVersionMetadata(GetVersionMetadataInput{
281508
Account: Account{
@@ -288,8 +515,10 @@ var _ = Describe("AppStore (GetVersionMetadata)", func() {
288515
})
289516

290517
Expect(err).NotTo(HaveOccurred())
291-
Expect(output.DisplayVersion).To(Equal("1.0.0"))
292-
Expect(output.ReleaseDate).To(Equal(time.Date(2024, 3, 20, 12, 0, 0, 0, time.UTC)))
518+
Expect(output.DisplayVersion).To(Equal(displayVersion))
519+
Expect(output.ReleaseDate).To(Equal(releaseDate))
520+
Expect(atomic.LoadInt64(wholeGetCount)).To(BeZero())
521+
Expect(atomic.LoadInt64(servedBytes)).To(BeNumerically("<", int64(len(ipa)/2)))
293522
})
294523
})
295524
})

0 commit comments

Comments
 (0)