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
11 changes: 6 additions & 5 deletions pkg/appstore/appstore_download.go
Original file line number Diff line number Diff line change
Expand Up @@ -216,11 +216,12 @@ func isTopLevelAppInfoPlist(path string) bool {
}

type downloadItemResult struct {
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"`
PreflightPackageURL string `plist:"preflightPackageURL,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 {
Expand Down
10 changes: 10 additions & 0 deletions pkg/appstore/appstore_get_version_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,16 @@ func (t *appstore) GetVersionMetadata(input GetVersionMetadataInput) (GetVersion
}

item := res.Data.Items[0]
// A pinned Mac version can be returned even when no platform was supplied.
// Use the response's package type instead of passing it to the IPA reader.
if input.Platform == "" {
softwarePlatform := downloadMetadataString(item.Metadata, "software-platform")
if strings.EqualFold(softwarePlatform, "macos") ||
(softwarePlatform == "" && strings.EqualFold(downloadMetadataString(item.Metadata, "product-type"), "mac-os-app")) {
platform = PlatformMacOS
}
}

if platform == PlatformMacOS {
packagePlatform, err := downloadPackagePlatform(platform, item)
if err != nil {
Expand Down
149 changes: 149 additions & 0 deletions pkg/appstore/appstore_macos_preflight_metadata.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
package appstore

import (
"encoding/xml"
"errors"
"fmt"
"io"
"strconv"
"strings"
"time"

"github.com/blacktop/go-macho/pkg/xar"
)

type macPreflightDistributionInfo struct {
XMLName xml.Name `xml:"installer-gui-script"`
Products []struct {
ID string `xml:"id,attr"`
Version string `xml:"version,attr"`
} `xml:"product"`
Packages []struct {
ID string `xml:"id,attr"`
Version string `xml:"version,attr"`
Bundles []struct {
ID string `xml:"id,attr"`
} `xml:"relocate>bundle"`
} `xml:"pkg-ref"`
}

func macPreflightVersionMetadata(archive *xar.Reader, bundleID string) (versionMetadata, error) {
var distribution *xar.File

for _, file := range archive.Files {
if file.Name != "Distribution" {
continue
}

if distribution != nil {
return versionMetadata{}, errors.New("macOS preflight package contains multiple Distribution files")
}

distribution = file
}

if distribution == nil {
return versionMetadata{}, errors.New("macOS preflight package does not contain Distribution")
}

if !distribution.VerifyChecksum() {
return versionMetadata{}, errors.New("macOS preflight Distribution checksum mismatch")
}

source, err := distribution.Open()
if err != nil {
return versionMetadata{}, fmt.Errorf("failed to open Mac preflight Distribution: %w", err)
}

data, readErr := io.ReadAll(source)
closeErr := source.Close()

if err := errors.Join(readErr, closeErr); err != nil {
return versionMetadata{}, fmt.Errorf("failed to read Mac preflight Distribution: %w", err)
}

var info macPreflightDistributionInfo
if err := xml.Unmarshal(data, &info); err != nil {
return versionMetadata{}, fmt.Errorf("failed to parse Mac preflight Distribution: %w", err)
}

if len(info.Products) != 1 || info.Products[0].ID == "" || (bundleID != "" && info.Products[0].ID != bundleID) {
return versionMetadata{}, errors.New("macOS preflight package does not contain a unique product for the requested bundle")
}

product := info.Products[0]
version := strings.TrimSpace(product.Version)

if version == "" {
return versionMetadata{}, errors.New("macOS preflight package does not contain a display version")
}

// References to the same package can be split across several pkg-ref
// elements. Only use versions belonging to the requested product's bundle.
packageIDs := make(map[string]bool)

for _, pkg := range info.Packages {
for _, bundle := range pkg.Bundles {
if bundle.ID == product.ID && pkg.ID != "" {
packageIDs[pkg.ID] = true
}
}
}

var built time.Time

for _, pkg := range info.Packages {
if !packageIDs[pkg.ID] || pkg.Version == "" {
continue
}

timestamp, err := legacyMacPackageBuildTime(version, pkg.Version)
if err != nil {
return versionMetadata{}, err
}

if !built.IsZero() && !built.Equal(timestamp) {
return versionMetadata{}, errors.New("macOS preflight package contains conflicting build timestamps")
}

built = timestamp
}

if built.IsZero() {
return versionMetadata{}, errors.New("macOS preflight package does not contain a build timestamp for the requested bundle")
}

// As with the payload timestamp fallback, this is the build time, not an
// exact store release date. The API date is app-level, and the preflight
// archive's creation time can reflect a much later repackaging date.
return versionMetadata{DisplayVersion: version, ReleaseDate: built}, nil
}

func legacyMacPackageBuildTime(displayVersion, packageVersion string) (time.Time, error) {
// Legacy Apple installers use major.minor.patch.0.1.<Unix build time>.
// Do not interpret arbitrary dotted versions as timestamps.
parts := strings.Split(packageVersion, ".")
version := strings.Split(displayVersion, ".")

for len(version) < 3 {
version = append(version, "0")
}

if len(parts) != 6 || len(version) != 3 || strings.Join(parts[:3], ".") != strings.Join(version, ".") ||
parts[3] != "0" || parts[4] != "1" || len(parts[5]) != 10 {
return time.Time{}, errors.New("macOS preflight package does not contain a supported build timestamp")
}

for _, part := range parts {
if part == "" || strings.Trim(part, "0123456789") != "" {
return time.Time{}, errors.New("macOS preflight package contains an invalid installer version")
}
}

seconds, err := strconv.ParseInt(parts[5], 10, 64)
if err != nil || seconds <= 0 {
return time.Time{}, errors.New("macOS preflight package contains an invalid build timestamp")
}

return time.Unix(seconds, 0).UTC(), nil
}
126 changes: 126 additions & 0 deletions pkg/appstore/appstore_macos_preflight_metadata_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
package appstore

import (
"bytes"
"crypto/sha1" // #nosec G505 -- XAR uses SHA-1 for archive checksums.
"fmt"
gohttp "net/http"
"net/http/httptest"
"os"
"strings"
"sync/atomic"
"time"

"github.com/blacktop/go-macho/pkg/xar"
"github.com/majd/ipatool/v2/pkg/http"
"github.com/majd/ipatool/v2/pkg/util/machine"
"github.com/majd/ipatool/v2/pkg/util/operatingsystem"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"go.uber.org/mock/gomock"
)

func macPreflightXAR(distribution string) []byte {
data := []byte(distribution)
checksum := sha1.Sum(data) // #nosec G401 -- XAR uses SHA-1 for archive checksums.
entry := fmt.Sprintf(`<file id="1"><type>file</type><name>Distribution</name><data><length>%d</length><offset>%d</offset><size>%d</size><encoding style="application/octet-stream"/><archived-checksum style="sha1">%x</archived-checksum><extracted-checksum style="sha1">%x</extracted-checksum></data></file>`, len(data), sha1.Size, len(data), checksum, checksum)

// The live preflight packages were repacked in 2023. Their creation time
// must not replace the original build timestamp in the installer version.
return makeTestXARArchive(`<creation-time>2023-01-04T22:53:15</creation-time>`+entry, data...)
}

func macPreflightDistribution(version, packageVersion string) string {
return fmt.Sprintf(`<installer-gui-script><product id="com.example.mac" version="%s"/>
<pkg-ref id="com.example.pkg"><relocate><bundle id="com.example.mac"/></relocate></pkg-ref>
<pkg-ref id="com.unrelated.pkg" version="9.9.9.0.1.1700000000"/>
<pkg-ref id="com.example.pkg" version="%s"/>
<pkg-ref id="com.example.pkg" version="%s"/></installer-gui-script>`, version, packageVersion, packageVersion)
}

var _ = Describe("Mac preflight version metadata", func() {
DescribeTable("validates the selected product and its build timestamp", func(distribution, bundle, expectedError string) {
data := macPreflightXAR(distribution)
archive, err := xar.NewReader(bytes.NewReader(data), int64(len(data)))
Expect(err).ToNot(HaveOccurred())
metadata, err := macPreflightVersionMetadata(archive, bundle)
if expectedError != "" {
Expect(err).To(MatchError(ContainSubstring(expectedError)))
} else {
Expect(err).ToNot(HaveOccurred())
Expect(metadata.DisplayVersion).To(Equal("4.2"))
Expect(metadata.ReleaseDate).To(Equal(time.Unix(1341568473, 0).UTC()))
}
},
Entry("unique product without a bundle filter", macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "", ""),
Entry("wrong bundle", macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "com.other.mac", "requested bundle"),
Entry("missing version", macPreflightDistribution("", "4.2.0.0.1.1341568473"), "", "display version"),
Entry("missing timestamp", macPreflightDistribution("4.2", ""), "", "build timestamp"),
Entry("ordinary dotted version", macPreflightDistribution("4.2", "4.2.0"), "", "supported build timestamp"),
Entry("different package version", macPreflightDistribution("4.2", "4.3.0.0.1.1341568473"), "", "supported build timestamp"),
Entry("invalid timestamp", macPreflightDistribution("4.2", "4.2.0.0.1.abcdefghij"), "", "invalid installer version"),
Entry("zero timestamp", macPreflightDistribution("4.2", "4.2.0.0.1.0000000000"), "", "invalid build timestamp"),
Entry("unrelated package", strings.ReplaceAll(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), `<bundle id="com.example.mac"`, `<bundle id="com.other.mac"`), "", "requested bundle"),
Entry("conflicting timestamps", strings.Replace(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "1341568473", "1341568474", 1), "", "conflicting build timestamps"),
Entry("ambiguous products", strings.Replace(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"), "</installer-gui-script>", `<product id="com.other.mac" version="4.2"/></installer-gui-script>`, 1), "", "unique product"),
Entry("malformed XML", "<", "", "failed to parse"),
)

It("rejects corrupted installer metadata", func() {
data := macPreflightXAR(macPreflightDistribution("4.2", "4.2.0.0.1.1341568473"))
data[len(data)-1] ^= 1
archive, err := xar.NewReader(bytes.NewReader(data), int64(len(data)))
Expect(err).ToNot(HaveOccurred())
_, err = macPreflightVersionMetadata(archive, "com.example.mac")
Expect(err).To(MatchError(ContainSubstring("checksum mismatch")))
})

DescribeTable("reads the selected legacy version without downloading the encrypted app", func(platform Platform, softwarePlatform, versionID, displayVersion, packageVersion string, timestamp int64) {
ctrl := gomock.NewController(GinkgoT())
defer ctrl.Finish()
tempRoot := GinkgoT().TempDir()
GinkgoT().Setenv("TMPDIR", tempRoot)
GinkgoT().Setenv("TMP", tempRoot)
GinkgoT().Setenv("TEMP", tempRoot)
data := macPreflightXAR(macPreflightDistribution(displayVersion, packageVersion))
var encryptedRequests atomic.Int64
server := httptest.NewServer(gohttp.HandlerFunc(func(w gohttp.ResponseWriter, r *gohttp.Request) {
if r.URL.Path != "/preflight.pkg" {
encryptedRequests.Add(1)
w.WriteHeader(gohttp.StatusInternalServerError)

return
}
_, _ = w.Write(data)
}))
defer server.Close()
downloads := http.NewMockClient[downloadResult](ctrl)
machines := machine.NewMockMachine(ctrl)
store := &appstore{downloadClient: downloads, machine: machines, os: operatingsystem.New(), httpClient: http.NewClient[interface{}](http.Args{})}
machines.EXPECT().MacAddress().Return("00:11:22:33:44:55", nil)
downloads.EXPECT().Send(gomock.Any()).Do(func(req http.Request) {
Expect(req.Payload.(*http.XMLPayload).Content).To(HaveKeyWithValue("externalVersionId", versionID))
}).Return(http.Result[downloadResult]{StatusCode: 200, Data: downloadResult{Items: []downloadItemResult{{
URL: server.URL + "/encrypted.pkg", PreflightPackageURL: server.URL + "/preflight.pkg",
Metadata: map[string]interface{}{
"software-platform": softwarePlatform, "product-type": "mac-os-app", "softwareVersionBundleId": "com.example.mac",
"bundleShortVersionString": "4.3", "releaseDate": "2011-01-03T08:00:00Z",
},
}}}}, nil)

out, err := store.GetVersionMetadata(GetVersionMetadataInput{App: App{ID: 409201541}, VersionID: versionID, Platform: platform})
Expect(err).ToNot(HaveOccurred())
Expect(out.DisplayVersion).To(Equal(displayVersion))
Expect(out.ReleaseDate).To(Equal(time.Unix(timestamp, 0).UTC()))
Expect(encryptedRequests.Load()).To(BeZero())
entries, err := os.ReadDir(tempRoot)
Expect(err).ToNot(HaveOccurred())
Expect(entries).To(BeEmpty())
},
Entry("4.0.5 without a platform", Platform(""), "macos", "3235485", "4.0.5", "4.0.5.0.1.1292967101", int64(1292967101)),
Entry("4.0.5 with macOS", PlatformMacOS, "macos", "3235485", "4.0.5", "4.0.5.0.1.1292967101", int64(1292967101)),
Entry("4.2 without a platform", Platform(""), "macos", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)),
Entry("4.2 with macOS", PlatformMacOS, "macos", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)),
Entry("product type without platform metadata", Platform(""), "", "12013122", "4.2", "4.2.0.0.1.1341568473", int64(1341568473)),
)
})
13 changes: 12 additions & 1 deletion pkg/appstore/appstore_macos_version_metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,14 @@ func (t *appstore) readVersionMetadataFromMacPackage(ctx context.Context, item d
defer os.RemoveAll(directory)

destination := filepath.Join(directory, "app.pkg")
if _, err := t.downloadMacPackage(ctx, item, destination, hardwareID, nil); err != nil {
// Older device-based downloads omit dpInfo but include a small, readable
// preflight archive containing the selected build's installer metadata.
preflight := !hasMacDPInfo(item.Sinfs) && item.PreflightPackageURL != ""
if preflight {
if err := t.downloadFile(ctx, item.PreflightPackageURL, destination, nil); err != nil {
return versionMetadata{}, fmt.Errorf("failed to download Mac preflight package: %w", err)
}
} else if _, err := t.downloadMacPackage(ctx, item, destination, hardwareID, nil); err != nil {
return versionMetadata{}, err
}

Expand All @@ -39,6 +46,10 @@ func (t *appstore) readVersionMetadataFromMacPackage(ctx context.Context, item d

defer archive.Close()

if preflight {
return macPreflightVersionMetadata(&archive.Reader, bundleID)
}

return macPackageVersionMetadata(&archive.Reader, bundleID)
}

Expand Down
Loading