diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..e02aaca --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,98 @@ +name: ci + +on: + push: + branches: + - "**" + tags: + - "v*.*.*" + pull_request: + +permissions: + contents: read + +jobs: + lint: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - uses: golangci/golangci-lint-action@v9 + with: + version: v2.13.1 + + vulncheck: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: go install golang.org/x/vuln/cmd/govulncheck@latest + # Binary mode avoids govulncheck's source-mode SSA, which panics on Go 1.26 + generics. + - run: go build -o appmeta ./cmd/appmeta + - run: govulncheck -mode=binary ./appmeta + + test: + runs-on: ubuntu-24.04-arm + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: go test -race ./... + + fuzz: + runs-on: ubuntu-24.04-arm + strategy: + fail-fast: false + matrix: + target: [FuzzAXML, FuzzAPKSigning, FuzzResourceTable, FuzzPlist, FuzzIcon, FuzzMachO, FuzzParse] + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - uses: actions/setup-go@v6 + with: + go-version-file: go.mod + cache: true + - run: go test -run '^$' -fuzz '^${{ matrix.target }}$' -fuzztime 60s -fuzzminimizetime 5s . + + release: + if: github.ref_type == 'tag' + needs: [lint, test, vulncheck] + runs-on: ubuntu-24.04-arm + permissions: + contents: write + steps: + - uses: actions/checkout@v5 + with: + persist-credentials: false + - name: Check the tag is vMAJOR.MINOR.PATCH + run: | + if [[ ! "$GITHUB_REF_NAME" =~ ^v[0-9]+\.[0-9]+\.[0-9]+$ ]]; then + echo "::error::Invalid release tag format: $GITHUB_REF_NAME" + exit 1 + fi + - name: Extract release notes from CHANGELOG.md + run: | + awk -v version="${GITHUB_REF_NAME#v}" '/^## \[/{f = ($0 ~ "^## \\[" version "\\]")} f' CHANGELOG.md > release-notes.md + if [[ ! -s release-notes.md ]]; then + echo "::error::CHANGELOG.md has no section for ${GITHUB_REF_NAME#v}" + exit 1 + fi + - uses: softprops/action-gh-release@v2 + with: + name: Version ${{ github.ref_name }} + body_path: release-notes.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..b528dc7 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +/appmeta +/dist/ diff --git a/.golangci.yml b/.golangci.yml new file mode 100644 index 0000000..26b4afd --- /dev/null +++ b/.golangci.yml @@ -0,0 +1 @@ +version: "2" diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3373b84 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,2 @@ +## [1.0.0](https://github.com/mobile-next/appmeta/releases/tag/v1.0.0) (2026-09-29) +* Initial version diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..f5e149e --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our +community a harassment-free experience for everyone, regardless of age, body +size, visible or invisible disability, ethnicity, sex characteristics, gender +identity and expression, level of experience, education, socio-economic status, +nationality, personal appearance, race, religion, or sexual identity +and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, +diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our +community include: + +* Demonstrating empathy and kindness toward other people +* Being respectful of differing opinions, viewpoints, and experiences +* Giving and gracefully accepting constructive feedback +* Accepting responsibility and apologizing to those affected by our mistakes, + and learning from the experience +* Focusing on what is best not just for us as individuals, but for the + overall community + +Examples of unacceptable behavior include: + +* The use of sexualized language or imagery, and sexual attention or + advances of any kind +* Trolling, insulting or derogatory comments, and personal or political attacks +* Public or private harassment +* Publishing others' private information, such as a physical or email + address, without their explicit permission +* Other conduct which could reasonably be considered inappropriate in a + professional setting + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of +acceptable behavior and will take appropriate and fair corrective action in +response to any behavior that they deem inappropriate, threatening, offensive, +or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject +comments, commits, code, wiki edits, issues, and other contributions that are +not aligned to this Code of Conduct, and will communicate reasons for moderation +decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when +an individual is officially representing the community in public spaces. +Examples of representing our community include using an official e-mail address, +posting via an official social media account, or acting as an appointed +representative at an online or offline event. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be +reported to the community leaders responsible for enforcement at +support@mobilenexthq.com. +All complaints will be reviewed and investigated promptly and fairly. + +All community leaders are obligated to respect the privacy and security of the +reporter of any incident. + +## Enforcement Guidelines + +Community leaders will follow these Community Impact Guidelines in determining +the consequences for any action they deem in violation of this Code of Conduct: + +### 1. Correction + +**Community Impact**: Use of inappropriate language or other behavior deemed +unprofessional or unwelcome in the community. + +**Consequence**: A private, written warning from community leaders, providing +clarity around the nature of the violation and an explanation of why the +behavior was inappropriate. A public apology may be requested. + +### 2. Warning + +**Community Impact**: A violation through a single incident or series +of actions. + +**Consequence**: A warning with consequences for continued behavior. No +interaction with the people involved, including unsolicited interaction with +those enforcing the Code of Conduct, for a specified period of time. This +includes avoiding interactions in community spaces as well as external channels +like social media. Violating these terms may lead to a temporary or +permanent ban. + +### 3. Temporary Ban + +**Community Impact**: A serious violation of community standards, including +sustained inappropriate behavior. + +**Consequence**: A temporary ban from any sort of interaction or public +communication with the community for a specified period of time. No public or +private interaction with the people involved, including unsolicited interaction +with those enforcing the Code of Conduct, is allowed during this period. +Violating these terms may lead to a permanent ban. + +### 4. Permanent Ban + +**Community Impact**: Demonstrating a pattern of violation of community +standards, including sustained inappropriate behavior, harassment of an +individual, or aggression toward or disparagement of classes of individuals. + +**Consequence**: A permanent ban from any sort of public interaction within +the community. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant][homepage], +version 2.0, available at +https://www.contributor-covenant.org/version/2/0/code_of_conduct.html. + +Community Impact Guidelines were inspired by [Mozilla's code of conduct +enforcement ladder](https://github.com/mozilla/diversity). + +[homepage]: https://www.contributor-covenant.org + +For answers to common questions about this code of conduct, see the FAQ at +https://www.contributor-covenant.org/faq. Translations are available at +https://www.contributor-covenant.org/translations. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..bb5931f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,46 @@ +# Contributing to appmeta + +Thanks for your interest in contributing! + +## Code of Conduct + +This project adheres to the [Contributor Covenant](CODE_OF_CONDUCT.md). + +## Reporting bugs + +- Search [existing issues](https://github.com/mobile-next/appmeta/issues) first. +- Include the appmeta version and the full error output. +- Do not attach third-party app binaries you have no right to share. A + minimal reproduction (for example a Go test that builds the input) is best. +- Security issues go through [SECURITY.md](SECURITY.md), not public issues. + +## Development + +Requirements: Go 1.25+. + +```bash +go test ./... +go vet ./... +golangci-lint run + +# fuzz one parser for 30 seconds +go test -run '^$' -fuzz '^FuzzAXML$' -fuzztime 30s . +``` + +Test fixtures are generated in Go test helpers, never checked-in third-party +binaries. Golden files live in `testdata/golden`; regenerate them with +`go test -run TestGolden -update .` and review the diff. + +## Rules of the road + +- Input is hostile. Every length read from a file is checked against the bytes + actually available and against the limits in `limits.go` before allocating. +- Parsers must not panic; `Parse` recovers anyway, but a panic is still a bug. +- No cgo, no temp files, no network, no filesystem paths taken from an archive. +- Changes to the JSON output must update `schema/appmeta.schema.json` and, if + they are not backwards compatible, bump `SchemaVersion`. + +## Submitting changes + +Use [Conventional Commits](https://www.conventionalcommits.org/) for commit +messages and PR titles (`feat: ...`, `fix: ...`, `docs: ...`). diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..c91fe35 --- /dev/null +++ b/Makefile @@ -0,0 +1,17 @@ +.PHONY: all test lint fmt vulncheck + +all: + CGO_ENABLED=0 go build -ldflags="-s -w" -o appmeta ./cmd/appmeta + +lint: + $(shell go env GOPATH)/bin/golangci-lint run + +fmt: + go fmt ./... + $(shell go env GOPATH)/bin/goimports -w . + +test: + go test -race ./... + +vulncheck: all + $(shell go env GOPATH)/bin/govulncheck -mode=binary ./appmeta diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..ed9334c --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +appmeta +Copyright 2026 Mobile Next + +This product includes software developed at Mobile Next (https://mobilenexthq.com/). diff --git a/README.md b/README.md index 284120e..54b6431 100644 --- a/README.md +++ b/README.md @@ -1 +1,42 @@ # appmeta + +Extract metadata from Android `.apk` and iOS `.ipa` files: bundle id, name, +version, build number, minimum OS, icon, signing, architectures and more. + +- Pure Go library and a small CLI. No cgo, no temp files, no network. +- Reads through an `io.ReaderAt`: only the zip central directory and the few + entries it needs are read, so it works well over HTTP range requests or S3. +- Built for untrusted input: bounded reads, configurable limits, fuzzed parsers. + +Licensed under the [Apache License 2.0](LICENSE). + +## Library + +```go +f, _ := os.Open("app.apk") +st, _ := f.Stat() +info, err := appmeta.Parse(f, st.Size()) +// info.BundleID, info.Version, info.Icon.PNG, ... +``` + +`Parse(r io.ReaderAt, size int64, opts ...Option) (*Info, error)`, or +`ParseContext(ctx, r, size, opts...)` to stop when a context is done. Tighten +limits with `appmeta.WithLimits(appmeta.Limits{...})`; zero fields keep their +defaults. The JSON shape is documented in +[`schema/appmeta.schema.json`](schema/appmeta.schema.json). + +## CLI + +```bash +go install github.com/mobile-next/appmeta/cmd/appmeta@latest +appmeta app.ipa # JSON on stdout +appmeta --no-icon app.apk # omit the base64 icon +appmeta --icon icon.png app.apk # also write the icon +``` + +On failure it prints `{"error": "..."}` and exits non-zero. + +## Development + +`make test`, `make lint`, `make vulncheck`. See [CONTRIBUTING.md](CONTRIBUTING.md) +and [docs/decisions.md](docs/decisions.md). diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..32924ed --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,17 @@ +# Security Policy + +appmeta parses untrusted `.apk` and `.ipa` files. A crash, hang, or unbounded +allocation caused by a crafted input is a security bug. + +## Supported versions + +Only the latest release receives security fixes. + +## Reporting a vulnerability + +Please do not open a public issue. Report privately through +[GitHub private vulnerability reporting](https://github.com/mobile-next/appmeta/security/advisories/new), +or join our Slack at http://mobilenexthq.com/join-slack and DM the moderators. + +Include the smallest input that reproduces the problem. We will respond as +quickly as possible. diff --git a/a.png b/a.png new file mode 100644 index 0000000..5547257 Binary files /dev/null and b/a.png differ diff --git a/apk.go b/apk.go new file mode 100644 index 0000000..b9c1ef9 --- /dev/null +++ b/apk.go @@ -0,0 +1,369 @@ +package appmeta + +import ( + "fmt" + "slices" + "strconv" + "strings" +) + +const ( + androidManifestPath = "AndroidManifest.xml" + androidResourcesPath = "resources.arsc" +) + +// Android framework attribute ids (android.R.attr). +const ( + attrLabel = 0x01010001 + attrIcon = 0x01010002 + attrName = 0x01010003 + attrDebuggable = 0x0101000f + attrMinSDKVersion = 0x0101020c + attrVersionCode = 0x0101021b + attrVersionName = 0x0101021c + attrTargetSDKVersion = 0x01010270 + attrRequired = 0x0101028e + attrDrawable = 0x01010199 +) + +// androidReleases maps API levels to the release users know them by. +var androidReleases = map[int]string{ + 1: "1.0", 2: "1.1", 3: "1.5", 4: "1.6", 5: "2.0", 6: "2.0.1", 7: "2.1", 8: "2.2", + 9: "2.3", 10: "2.3.3", 11: "3.0", 12: "3.1", 13: "3.2", 14: "4.0", 15: "4.0.3", + 16: "4.1", 17: "4.2", 18: "4.3", 19: "4.4", 20: "4.4W", 21: "5.0", 22: "5.1", + 23: "6.0", 24: "7.0", 25: "7.1", 26: "8.0", 27: "8.1", 28: "9", 29: "10", 30: "11", + 31: "12", 32: "12L", 33: "13", 34: "14", 35: "15", 36: "16", +} + +type apkFeature struct { + name string + required bool +} + +type apkManifest struct { + packageName string + versionCode string + versionName xmlAttr + label xmlAttr + icon xmlAttr + minSDK xmlAttr + targetSDK xmlAttr + debuggable bool + permissions []string + features []apkFeature + // seenPermissions keeps the deduplication of permissions linear. + seenPermissions map[string]struct{} +} + +func parseAPK(a *archive) (*Info, error) { + data, err := a.read(androidManifestPath) + if err != nil { + return nil, err + } + m, err := parseManifest(data, a.limits.MaxDepth) + if err != nil { + return nil, fmt.Errorf("%s: %w", androidManifestPath, err) + } + if m.packageName == "" { + return nil, fmt.Errorf("%s: %w: no package name", androidManifestPath, errMalformedResource) + } + + info := &Info{ + Format: FormatAPK, + Platform: PlatformAndroid, + BundleID: m.packageName, + BuildNumber: m.versionCode, + MinOSVersion: androidRelease(m.minSDK), + TargetOSVersion: androidRelease(m.targetSDK), + IsDebuggable: m.debuggable, + DeviceFamilies: androidDeviceFamilies(m.features), + Architectures: androidABIs(a), + Permissions: m.permissions, + } + res := &apkResources{archive: a} + var warning string + info.Version, warning = res.text(m.versionName, "android:versionName") + info.Warnings = appendWarning(info.Warnings, warning) + info.Name, warning = res.text(m.label, "android:label") + info.Warnings = appendWarning(info.Warnings, warning) + if info.Name == "" { + info.Name = m.packageName + } + info.Icon, warning = extractAPKIcon(res, m.icon) + info.Warnings = appendWarning(info.Warnings, warning) + if info.Signing, err = apkSigning(a); err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("signing unknown: %v", err)) + } + return info, nil +} + +// apkResources loads resources.arsc on first use; most manifests of simple +// apps need no lookups. +type apkResources struct { + archive *archive + table *resourceTable + err error + loaded bool +} + +func (r *apkResources) get() (*resourceTable, error) { + if !r.loaded { + r.loaded = true + r.table, r.err = r.load() + } + return r.table, r.err +} + +func (r *apkResources) load() (*resourceTable, error) { + data, err := r.archive.read(androidResourcesPath) + if err != nil { + return nil, err + } + table, err := parseResourceTable(data) + if err != nil { + return nil, fmt.Errorf("%s: %w", androidResourcesPath, err) + } + return table, nil +} + +// text returns an attribute's text, resolving references. Failures become +// a warning: the rest of the metadata is still useful. +func (r *apkResources) text(attr xmlAttr, field string) (text, warning string) { + if !attr.isReference() { + return attr.text(), "" + } + table, err := r.get() + if err != nil { + return "", fmt.Sprintf("%s %s not resolved: %v", field, formatResID(attr.data), err) + } + s, ok := table.resolveText(attr.data) + if !ok { + return "", fmt.Sprintf("%s %s not found in %s", field, formatResID(attr.data), androidResourcesPath) + } + return s, "" +} + +func parseManifest(data []byte, maxDepth int) (*apkManifest, error) { + m := &apkManifest{seenPermissions: map[string]struct{}{}} + if err := parseAXML(data, maxDepth, m.readElement); err != nil { + return nil, err + } + return m, nil +} + +func (m *apkManifest) readElement(path []string, attrs []xmlAttr) { + switch { + case pathIs(path, "manifest"): + m.readPackage(attrs) + case pathIs(path, "manifest", "uses-sdk"): + m.minSDK, _ = findAttr(attrs, attrMinSDKVersion, "minSdkVersion") + m.targetSDK, _ = findAttr(attrs, attrTargetSDKVersion, "targetSdkVersion") + case pathIs(path, "manifest", "application"): + m.readApplication(attrs) + case pathIs(path, "manifest", "uses-permission"), + pathIs(path, "manifest", "uses-permission-sdk-23"), + pathIs(path, "manifest", "uses-permission-sdk-m"): + m.addPermission(attrs) + case pathIs(path, "manifest", "uses-feature"): + m.addFeature(attrs) + } +} + +func (m *apkManifest) readPackage(attrs []xmlAttr) { + if a, ok := findAttr(attrs, 0, "package"); ok { + m.packageName = a.text() + } + if a, ok := findAttr(attrs, attrVersionCode, "versionCode"); ok { + m.versionCode = a.text() + } + m.versionName, _ = findAttr(attrs, attrVersionName, "versionName") +} + +func (m *apkManifest) readApplication(attrs []xmlAttr) { + m.label, _ = findAttr(attrs, attrLabel, "label") + m.icon, _ = findAttr(attrs, attrIcon, "icon") + if a, ok := findAttr(attrs, attrDebuggable, "debuggable"); ok { + m.debuggable = a.valueType == resValueTypeBool && a.data != 0 + } +} + +func (m *apkManifest) addPermission(attrs []xmlAttr) { + a, ok := findAttr(attrs, attrName, "name") + if !ok || a.text() == "" { + return + } + if _, seen := m.seenPermissions[a.text()]; seen { + return + } + m.seenPermissions[a.text()] = struct{}{} + m.permissions = append(m.permissions, a.text()) +} + +func (m *apkManifest) addFeature(attrs []xmlAttr) { + feature := apkFeature{required: true} + if a, ok := findAttr(attrs, attrName, "name"); ok { + feature.name = a.text() + } + if a, ok := findAttr(attrs, attrRequired, "required"); ok && a.valueType == resValueTypeBool { + feature.required = a.data != 0 + } + m.features = append(m.features, feature) +} + +// extractAPKIcon returns the icon, a warning, or for an adaptive icon both. +func extractAPKIcon(res *apkResources, attr xmlAttr) (*Icon, string) { + if !attr.isReference() { + return nil, "" + } + table, err := res.get() + if err != nil { + return nil, fmt.Sprintf("icon not extracted: %v", err) + } + files := table.resolveFiles(attr.data) + icon, err := firstDecodableRaster(res.archive, files) + if icon != nil { + return icon, "" + } + if icon := adaptiveIconForegroundRaster(res.archive, table, files); icon != nil { + return icon, "adaptive icon rendered from foreground layer only" + } + if err != nil { + return nil, fmt.Sprintf("icon not extracted: %v", err) + } + return nil, "icon not extracted: no raster image found (vector drawables are not rendered)" +} + +// adaptiveIconForegroundRaster returns nil when none of the files is an +// adaptive icon with a raster foreground. +func adaptiveIconForegroundRaster(a *archive, table *resourceTable, files []resourceFile) *Icon { + for _, f := range files { + if !strings.HasSuffix(f.path, ".xml") { + continue + } + foreground, ok := adaptiveIconForeground(a, f.path) + if !ok { + continue + } + if icon, _ := firstDecodableRaster(a, table.resolveFiles(foreground)); icon != nil { + return icon + } + } + return nil +} + +// firstDecodableRaster tries raster files from the highest density down. +func firstDecodableRaster(a *archive, files []resourceFile) (*Icon, error) { + var rasters []resourceFile + for _, f := range files { + if isRasterPath(f.path) { + rasters = append(rasters, f) + } + } + slices.SortStableFunc(rasters, func(x, y resourceFile) int { + return densityRank(y.density) - densityRank(x.density) + }) + var lastErr error + for _, f := range rasters { + data, err := a.read(f.path) + if err != nil { + lastErr = err + continue + } + icon, err := encodeIcon(data, a.limits.MaxIconPixels) + if err != nil { + lastErr = fmt.Errorf("%s: %w", f.path, err) + continue + } + return icon, nil + } + return nil, lastErr +} + +func isRasterPath(path string) bool { + for _, ext := range []string{".png", ".webp", ".jpg", ".jpeg"} { + if strings.HasSuffix(strings.ToLower(path), ext) { + return true + } + } + return false +} + +// densityRank orders densities for icon choice: the default configuration +// counts as mdpi, and nodpi as the lowest. +func densityRank(density uint16) int { + switch density { + case 0: + return densityMedium + case densityNone, densityAny: + return 0 + } + return int(density) +} + +// adaptiveIconForeground returns the drawable of an adaptive icon's +// foreground layer, which may sit on the element or on a nested one such as +// . +func adaptiveIconForeground(a *archive, path string) (uint32, bool) { + data, err := a.read(path) + if err != nil { + return 0, false + } + var found uint32 + err = parseAXML(data, a.limits.MaxDepth, func(p []string, attrs []xmlAttr) { + if found != 0 || len(p) < 2 || p[0] != "adaptive-icon" || p[1] != "foreground" { + return + } + if d, ok := findAttr(attrs, attrDrawable, "drawable"); ok && d.isReference() { + found = d.data + } + }) + return found, err == nil && found != 0 +} + +// androidRelease maps an SDK attribute to a release name. Preview builds use +// a codename string, which is passed through. +func androidRelease(sdk xmlAttr) string { + text := sdk.text() + level, err := strconv.Atoi(text) + if err != nil { + return text + } + if release, ok := androidReleases[level]; ok { + return release + } + return "API " + text +} + +func androidDeviceFamilies(features []apkFeature) []string { + requires := func(name string) bool { + return slices.Contains(features, apkFeature{name: name, required: true}) + } + switch { + case requires("android.hardware.type.watch"): + return []string{"watch"} + case requires("android.hardware.type.automotive"): + return []string{"car"} + case requires("android.software.leanback"): + return []string{"tv"} + case slices.Contains(features, apkFeature{name: "android.software.leanback"}): + return []string{"phone", "tablet", "tv"} + } + return []string{"phone", "tablet"} +} + +// androidABIs lists the lib// directories that contain files. +func androidABIs(a *archive) []string { + var abis []string + for name := range a.files { + rest, ok := strings.CutPrefix(name, "lib/") + if !ok { + continue + } + abi, file, ok := strings.Cut(rest, "/") + if ok && abi != "" && file != "" && !strings.HasSuffix(file, "/") && !slices.Contains(abis, abi) { + abis = append(abis, abi) + } + } + slices.Sort(abis) + return abis +} diff --git a/apk_test.go b/apk_test.go new file mode 100644 index 0000000..3b40232 --- /dev/null +++ b/apk_test.go @@ -0,0 +1,151 @@ +package appmeta + +import ( + "errors" + "slices" + "testing" +) + +func TestAnAPKManifestYieldsPackageVersionAndOSReleases(t *testing.T) { + info := mustParse(t, buildMinimalAPK(t)) + assertEqual(t, "format", info.Format, "apk") + assertEqual(t, "platform", info.Platform, "android") + assertEqual(t, "bundleId", info.BundleID, "com.acme.minimal") + assertEqual(t, "version", info.Version, "4.2.0") + assertEqual(t, "buildNumber", info.BuildNumber, "4201") + assertEqual(t, "minOsVersion", info.MinOSVersion, "8.0") + assertEqual(t, "targetOsVersion", info.TargetOSVersion, "15") + assertEqual(t, "name", info.Name, "Minimal") +} + +func TestAnAPKWithoutALabelFallsBackToThePackageName(t *testing.T) { + info := mustParse(t, buildAPKWithManifest(t, manifestElement("com.acme.nolabel", nil))) + assertEqual(t, "name", info.Name, "com.acme.nolabel") +} + +func TestADebuggableAPKIsReportedAsDebuggable(t *testing.T) { + manifest := manifestElement("com.acme.debug", attrs(androidBool("debuggable", attrDebuggable, true))) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + if !info.IsDebuggable { + t.Fatal("want isDebuggable") + } +} + +func TestPermissionsAreListedOnceInManifestOrder(t *testing.T) { + manifest := manifestElement("com.acme.perms", nil, + usesPermission("android.permission.INTERNET"), + usesPermission("android.permission.CAMERA"), + usesPermission("android.permission.INTERNET"), + ) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + assertSlice(t, "permissions", info.Permissions, []string{"android.permission.INTERNET", "android.permission.CAMERA"}) +} + +func TestNativeLibraryDirectoriesBecomeArchitectures(t *testing.T) { + data := buildAPKWithManifest(t, manifestElement("com.acme.native", nil), + zipEntry{name: "lib/armeabi-v7a/libacme.so", data: []byte("elf")}, + zipEntry{name: "lib/arm64-v8a/libacme.so", data: []byte("elf")}, + zipEntry{name: "lib/arm64-v8a/libother.so", data: []byte("elf")}, + zipEntry{name: "lib/x86/", data: nil}, + ) + info := mustParse(t, data) + assertSlice(t, "architectures", info.Architectures, []string{"arm64-v8a", "armeabi-v7a"}) +} + +func TestAnAPKWithoutNativeLibrariesHasNoArchitectures(t *testing.T) { + info := mustParse(t, buildMinimalAPK(t)) + assertSlice(t, "architectures", info.Architectures, []string{}) +} + +func TestAnUnknownFutureAPILevelIsReportedAsAnAPILevel(t *testing.T) { + manifest := element("manifest", attrs(plainString("package", "com.acme.future")), + element("uses-sdk", attrs(androidInt("minSdkVersion", attrMinSDKVersion, 99)))) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + assertEqual(t, "minOsVersion", info.MinOSVersion, "API 99") +} + +func TestAPreviewCodenameSDKIsPassedThrough(t *testing.T) { + manifest := element("manifest", attrs(plainString("package", "com.acme.preview")), + element("uses-sdk", attrs(androidString("targetSdkVersion", attrTargetSDKVersion, "Baklava")))) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + assertEqual(t, "targetOsVersion", info.TargetOSVersion, "Baklava") +} + +func TestObfuscatedAttributeNamesAreFoundByResourceID(t *testing.T) { + manifest := element("manifest", attrs( + plainString("package", "com.acme.obfuscated"), + androidString("", attrVersionName, "9.9"), + androidInt("x", attrVersionCode, 99), + )) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + assertEqual(t, "version", info.Version, "9.9") + assertEqual(t, "buildNumber", info.BuildNumber, "99") +} + +func TestUTF8StringPoolsDecodeLikeUTF16Ones(t *testing.T) { + manifest := manifestElement("com.acme.utf8", attrs(androidString("label", attrLabel, "Café ☕"))) + data := buildZip(t, zipEntry{name: androidManifestPath, data: encodeAXMLStrings(manifest, true)}) + info := mustParse(t, data) + assertEqual(t, "name", info.Name, "Café ☕") +} + +func TestDeviceFamiliesFollowRequiredHardwareFeatures(t *testing.T) { + cases := map[string]struct { + features []xmlNode + want []string + }{ + "phone app": {nil, []string{"phone", "tablet"}}, + "watch app": {[]xmlNode{usesFeature("android.hardware.type.watch", true)}, []string{"watch"}}, + "tv-only app": {[]xmlNode{usesFeature("android.software.leanback", true)}, []string{"tv"}}, + "app that has tv": {[]xmlNode{usesFeature("android.software.leanback", false)}, []string{"phone", "tablet", "tv"}}, + "car app": {[]xmlNode{usesFeature("android.hardware.type.automotive", true)}, []string{"car"}}, + "optional watch": {[]xmlNode{usesFeature("android.hardware.type.watch", false)}, []string{"phone", "tablet"}}, + } + for name, c := range cases { + t.Run(name, func(t *testing.T) { + info := mustParse(t, buildAPKWithManifest(t, manifestElement("com.acme.devices", nil, c.features...))) + assertSlice(t, "deviceFamilies", info.DeviceFamilies, c.want) + }) + } +} + +func TestAManifestWithoutAPackageNameIsAnError(t *testing.T) { + _, err := parseBytes(buildAPKWithManifest(t, element("manifest", nil))) + if err == nil { + t.Fatal("want an error") + } +} + +func TestAManifestNestedDeeperThanTheLimitIsRejected(t *testing.T) { + deep := element("leaf", nil) + for range 10 { + deep = element("wrapper", nil, deep) + } + manifest := element("manifest", attrs(plainString("package", "com.acme.deep")), deep) + _, err := parseBytes(buildAPKWithManifest(t, manifest), WithLimits(Limits{MaxDepth: 5})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func TestATruncatedManifestIsAnError(t *testing.T) { + manifest := encodeAXML(manifestElement("com.acme.truncated", nil)) + data := buildZip(t, zipEntry{name: androidManifestPath, data: manifest[:len(manifest)/2]}) + if _, err := parseBytes(data); err == nil { + t.Fatal("want an error") + } +} + +func assertEqual[T comparable](t *testing.T, field string, got, want T) { + t.Helper() + if got != want { + t.Errorf("%s: got %v, want %v", field, got, want) + } +} + +func assertSlice(t *testing.T, field string, got, want []string) { + t.Helper() + if !slices.Equal(got, want) { + t.Errorf("%s: got %q, want %q", field, got, want) + } +} diff --git a/apksign.go b/apksign.go new file mode 100644 index 0000000..36eef24 --- /dev/null +++ b/apksign.go @@ -0,0 +1,212 @@ +package appmeta + +import ( + "bytes" + "crypto/x509" + "encoding/asn1" + "encoding/binary" + "fmt" + "slices" + "strings" +) + +// APK signatures are read only to tell debug builds from release builds; the +// signatures themselves are not verified. + +const ( + signingBlockFooterLen = 24 + signingBlockPairIDLen = 4 + signingBlockV2ID = 0x7109871a + signingBlockV3ID = 0xf05368c0 + signingBlockV31ID = 0x1b93ad61 + signingBlockLenLen = 8 + lengthPrefixLen = 4 +) + +var ( + signingBlockMagic = []byte("APK Sig Block 42") + errMalformedSigning = fmt.Errorf("%w: apk signature", ErrMalformed) + // Newest scheme first: v3.1 and v3 may rotate to a new key, v2 cannot. + signingSchemes = []uint32{signingBlockV31ID, signingBlockV3ID, signingBlockV2ID} +) + +// apkSigning returns nil for an unsigned APK. It prefers the APK Signing +// Block (v2+), which modern builds may use exclusively, over v1 JAR signing. +func apkSigning(a *archive) (*Signing, error) { + cert, err := signingBlockCertificate(a) + if err != nil { + return nil, err + } + if cert == nil { + if cert, err = jarSignatureCertificate(a); err != nil || cert == nil { + return nil, err + } + } + parsed, err := x509.ParseCertificate(cert) + if err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + return &Signing{Type: signingTypeOf(parsed)}, nil +} + +// signingTypeOf recognises the key Android build tools generate for debug +// builds (~/.android/debug.keystore). +func signingTypeOf(cert *x509.Certificate) string { + subject := cert.Subject + if subject.CommonName == "Android Debug" && slices.Contains(subject.Organization, "Android") && slices.Contains(subject.Country, "US") { + return SigningDebug + } + return SigningRelease +} + +// signingBlockCertificate finds the APK Signing Block that sits right before +// the central directory and returns the first signer's first certificate. +func signingBlockCertificate(a *archive) ([]byte, error) { + cdOffset := a.directory.offset + if cdOffset < signingBlockFooterLen+signingBlockLenLen { + return nil, nil + } + footer, err := a.readRange(cdOffset-signingBlockFooterLen, signingBlockFooterLen) + if err != nil { + return nil, err + } + if !bytes.Equal(footer[signingBlockLenLen:], signingBlockMagic) { + return nil, nil + } + // The size excludes the leading size field itself. + size := binary.LittleEndian.Uint64(footer) + if size < signingBlockFooterLen || size > cdOffset-signingBlockLenLen || size > uint64(a.limits.MaxEntrySize) { + return nil, fmt.Errorf("%w: signing block of %d bytes", errMalformedSigning, size) + } + block, err := a.readRange(cdOffset-size-signingBlockLenLen, size+signingBlockLenLen) + if err != nil { + return nil, err + } + if binary.LittleEndian.Uint64(block) != size { + return nil, fmt.Errorf("%w: signing block sizes disagree", errMalformedSigning) + } + return certificateFromSigningPairs(block[signingBlockLenLen : len(block)-signingBlockFooterLen]) +} + +// certificateFromSigningPairs walks the block's (length, id, value) pairs. +func certificateFromSigningPairs(pairs []byte) ([]byte, error) { + values := map[uint32][]byte{} + for len(pairs) > 0 { + if len(pairs) < signingBlockLenLen { + return nil, fmt.Errorf("%w: truncated pair", errMalformedSigning) + } + n := binary.LittleEndian.Uint64(pairs) + pairs = pairs[signingBlockLenLen:] + if n < signingBlockPairIDLen || n > uint64(len(pairs)) { + return nil, fmt.Errorf("%w: pair of %d bytes", errMalformedSigning, n) + } + id := binary.LittleEndian.Uint32(pairs) + if _, seen := values[id]; !seen { + values[id] = pairs[signingBlockPairIDLen:n] + } + pairs = pairs[n:] + } + for _, id := range signingSchemes { + if value, ok := values[id]; ok { + return firstSignerCertificate(value) + } + } + return nil, nil +} + +// firstSignerCertificate reads signers -> signer -> signed data -> (skip +// digests) -> certificates -> first certificate, each prefixed by a uint32 +// length (the v2 and v3 layouts agree up to here). +func firstSignerCertificate(value []byte) ([]byte, error) { + signers, _, err := lengthPrefixed(value) + if err != nil { + return nil, err + } + signer, _, err := lengthPrefixed(signers) + if err != nil { + return nil, err + } + signedData, _, err := lengthPrefixed(signer) + if err != nil { + return nil, err + } + _, rest, err := lengthPrefixed(signedData) + if err != nil { + return nil, err + } + certs, _, err := lengthPrefixed(rest) + if err != nil { + return nil, err + } + cert, _, err := lengthPrefixed(certs) + return cert, err +} + +func lengthPrefixed(b []byte) ([]byte, []byte, error) { + if len(b) < lengthPrefixLen { + return nil, nil, fmt.Errorf("%w: truncated length", errMalformedSigning) + } + n := uint64(binary.LittleEndian.Uint32(b)) + if n > uint64(len(b)-lengthPrefixLen) { + return nil, nil, fmt.Errorf("%w: length %d overflows", errMalformedSigning, n) + } + return b[lengthPrefixLen : lengthPrefixLen+n], b[lengthPrefixLen+n:], nil +} + +// jarSignatureCertificate reads the first v1 signature block +// (META-INF/*.RSA, .DSA or .EC), a PKCS#7 SignedData. +func jarSignatureCertificate(a *archive) ([]byte, error) { + var path string + for name := range a.files { + file, ok := strings.CutPrefix(name, "META-INF/") + isSignature := strings.HasSuffix(file, ".RSA") || strings.HasSuffix(file, ".DSA") || strings.HasSuffix(file, ".EC") + if ok && isSignature && !strings.Contains(file, "/") && (path == "" || name < path) { + path = name + } + } + if path == "" { + return nil, nil + } + data, err := a.read(path) + if err != nil { + return nil, err + } + return pkcs7FirstCertificate(data) +} + +// pkcs7FirstCertificate walks ContentInfo -> [0] SignedData -> [0] +// certificates with encoding/asn1, which checks every length against the +// input. +func pkcs7FirstCertificate(der []byte) ([]byte, error) { + var contentInfo asn1.RawValue + if _, err := asn1.Unmarshal(der, &contentInfo); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + var oid asn1.ObjectIdentifier + rest, err := asn1.Unmarshal(contentInfo.Bytes, &oid) + if err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + var explicit, signedData asn1.RawValue + if _, err := asn1.Unmarshal(rest, &explicit); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + if _, err := asn1.Unmarshal(explicit.Bytes, &signedData); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + fields := signedData.Bytes + for len(fields) > 0 { + var field asn1.RawValue + if fields, err = asn1.Unmarshal(fields, &field); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + if field.Class == asn1.ClassContextSpecific && field.Tag == 0 { + var cert asn1.RawValue + if _, err := asn1.Unmarshal(field.Bytes, &cert); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedSigning, err) + } + return cert.FullBytes, nil + } + } + return nil, fmt.Errorf("%w: no certificates", errMalformedSigning) +} diff --git a/apksign_test.go b/apksign_test.go new file mode 100644 index 0000000..e312d93 --- /dev/null +++ b/apksign_test.go @@ -0,0 +1,63 @@ +package appmeta + +import "testing" + +func TestAnAPKSignedWithTheDebugKeyIsADebugBuild(t *testing.T) { + info := mustParse(t, signWithV2(t, buildMinimalAPK(t), androidDebugSubject)) + assertSigningType(t, info, "debug") +} + +func TestAnAPKSignedWithAnyOtherKeyIsAReleaseBuild(t *testing.T) { + info := mustParse(t, buildAcmeShopAPK(t)) + assertSigningType(t, info, "release") +} + +func TestAV1OnlySignatureIsRead(t *testing.T) { + info := mustParse(t, buildAdaptiveIconAPK(t)) + assertSigningType(t, info, "debug") +} + +func TestTheV3SignerWinsOverTheV2Signer(t *testing.T) { + block := signingBlock( + signingPair{id: signingBlockV2ID, value: v2SignerValue(selfSignedCertificate(t, androidDebugSubject))}, + signingPair{id: signingBlockV3ID, value: v2SignerValue(selfSignedCertificate(t, acmeReleaseSubject))}, + ) + info := mustParse(t, withSigningBlock(t, buildMinimalAPK(t), block)) + assertSigningType(t, info, "release") +} + +func TestTheSigningBlockWinsOverTheV1Signature(t *testing.T) { + info := mustParse(t, signWithV2(t, buildAdaptiveIconAPK(t), acmeReleaseSubject)) + assertSigningType(t, info, "release") +} + +func TestAnUnsignedAPKHasNoSigning(t *testing.T) { + info := mustParse(t, buildMinimalAPK(t)) + if info.Signing != nil { + t.Fatalf("want no signing, got %+v", info.Signing) + } +} + +func TestACorruptSigningBlockIsAWarningNotAnError(t *testing.T) { + block := signingBlock(signingPair{id: signingBlockV2ID, value: []byte{0xFF, 0xFF, 0xFF, 0xFF}}) + info := mustParse(t, withSigningBlock(t, buildMinimalAPK(t), block)) + if info.Signing != nil { + t.Fatal("want no signing") + } + assertWarningMentions(t, info, "signing unknown") +} + +func TestACorruptV1SignatureIsAWarningNotAnError(t *testing.T) { + data := buildAPKWithManifest(t, manifestElement("com.acme.v1", nil), + zipEntry{name: "META-INF/CERT.RSA", data: []byte("\x30\x03\x02\x01")}) + info := mustParse(t, data) + assertWarningMentions(t, info, "signing unknown") +} + +func assertSigningType(t *testing.T, info *Info, want string) { + t.Helper() + if info.Signing == nil { + t.Fatalf("want %s signing, got none (warnings %q)", want, info.Warnings) + } + assertEqual(t, "signing type", info.Signing.Type, want) +} diff --git a/appmeta.go b/appmeta.go new file mode 100644 index 0000000..5e49474 --- /dev/null +++ b/appmeta.go @@ -0,0 +1,209 @@ +// Package appmeta extracts metadata from Android .apk and iOS .ipa files. +// +// Parse reads through an io.ReaderAt and touches only the zip central +// directory and the few entries it needs, so the input can live behind HTTP +// range requests or S3 ranged GETs. Input is treated as hostile: every read is +// bounded by Limits and Parse never lets a panic escape. +// +// ParseContext stops when its context is done; the limits bound the CPU work +// done between reads. +package appmeta + +import ( + "context" + "errors" + "fmt" + "io" + "time" +) + +// SchemaVersion is the version of the JSON shape of Info. It changes only on +// backwards-incompatible changes to schema/appmeta.schema.json. +const SchemaVersion = 1 + +// Values of Info.Format and Info.Platform. +const ( + FormatAPK = "apk" + FormatIPA = "ipa" + PlatformAndroid = "android" + PlatformIOS = "ios" +) + +// Values of Signing.Type: the first four on iOS, the last two on Android. +const ( + SigningDevelopment = "development" + SigningAdHoc = "ad-hoc" + SigningEnterprise = "enterprise" + SigningAppStore = "app-store" + SigningDebug = "debug" + SigningRelease = "release" +) + +// Info is the metadata of one app binary. String fields are empty when the +// binary does not declare them. +type Info struct { + SchemaVersion int `json:"schemaVersion"` + Format string `json:"format"` // FormatAPK or FormatIPA + Platform string `json:"platform"` // PlatformAndroid or PlatformIOS + BundleID string `json:"bundleId"` + Name string `json:"name"` + Version string `json:"version"` + BuildNumber string `json:"buildNumber"` + MinOSVersion string `json:"minOsVersion"` + TargetOSVersion string `json:"targetOsVersion"` + IsSimulator bool `json:"isSimulator"` + IsDebuggable bool `json:"isDebuggable"` + DeviceFamilies []string `json:"deviceFamilies"` + Architectures []string `json:"architectures"` + Permissions []string `json:"permissions"` + Signing *Signing `json:"signing"` + Icon *Icon `json:"icon"` + Warnings []string `json:"warnings"` +} + +// Signing describes how an app is signed. For iOS it summarises the +// provisioning profile; for Android it tells debug-key builds from release +// builds, and TeamID and ExpiresAt are empty. +type Signing struct { + // One of the Signing constants. + Type string `json:"type"` + TeamID string `json:"teamId"` + ExpiresAt *time.Time `json:"expiresAt"` +} + +// Icon is the app icon, re-encoded as PNG and at most MaxIconSize pixels on +// each side. +type Icon struct { + ContentType string `json:"contentType"` + Width int `json:"width"` + Height int `json:"height"` + SHA256 string `json:"sha256"` + // PNG holds the encoded image; it is base64 in JSON. + PNG []byte `json:"base64,omitempty"` +} + +var ( + // ErrUnsupportedFormat is returned for input that is not an APK or IPA. + ErrUnsupportedFormat = errors.New("appmeta: not an apk or ipa") + // ErrLimitExceeded is returned when the input exceeds one of the Limits. + ErrLimitExceeded = errors.New("appmeta: limit exceeded") + // ErrMalformed is returned for an APK or IPA whose contents cannot be + // parsed. Errors that are none of these three come from reading the input. + ErrMalformed = errors.New("appmeta: malformed input") +) + +// Parse extracts metadata from the APK or IPA of the given size read through r. +func Parse(r io.ReaderAt, size int64, opts ...Option) (*Info, error) { + return ParseContext(context.Background(), r, size, opts...) +} + +// parseResult carries the outcome of the parsing goroutine. +type parseResult struct { + info *Info + err error +} + +// ParseContext is Parse that gives up when ctx is done. Every read from r +// checks ctx first; a read that blocks is abandoned and the call returns at +// once, while the parsing goroutine exits when that read returns. +func ParseContext(ctx context.Context, r io.ReaderAt, size int64, opts ...Option) (*Info, error) { + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("appmeta: %w", err) + } + cfg := config{limits: DefaultLimits()} + for _, opt := range opts { + opt(&cfg) + } + + done := make(chan parseResult, 1) + go func() { + // The host must survive any input, so a parser bug becomes an error. + defer func() { + if p := recover(); p != nil { + done <- parseResult{err: fmt.Errorf("appmeta: internal error: %v", p)} + } + }() + info, err := parse(contextReaderAt{ctx: ctx, r: r}, size, cfg.limits) + done <- parseResult{info: info, err: err} + }() + + select { + case res := <-done: + // Reads refused after ctx was done leave the metadata incomplete. + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("appmeta: %w", err) + } + return res.info, res.err + case <-ctx.Done(): + return nil, fmt.Errorf("appmeta: %w", ctx.Err()) + } +} + +// contextReaderAt fails reads once ctx is done, which stops parsing between +// zip entries and inside decompression loops. +type contextReaderAt struct { + ctx context.Context + r io.ReaderAt +} + +func (c contextReaderAt) ReadAt(p []byte, off int64) (int, error) { + if err := c.ctx.Err(); err != nil { + return 0, fmt.Errorf("appmeta: %w", err) + } + return c.r.ReadAt(p, off) +} + +// parse is Parse without the panic recovery, so fuzzing sees panics. +func parse(r io.ReaderAt, size int64, limits Limits) (*Info, error) { + a, err := openArchive(r, size, limits) + if err != nil { + return nil, err + } + + var info *Info + switch { + case a.has(androidManifestPath): + info, err = parseAPK(a) + case findAppBundle(a) != "": + info, err = parseIPA(a) + default: + return nil, ErrUnsupportedFormat + } + // Metadata read through a failing input is incomplete, whatever was + // parsed from it. + if failure := a.r.failure(); failure != nil { + return nil, failure + } + if err != nil { + return nil, err + } + info.SchemaVersion = SchemaVersion + fillEmptyLists(info) + return info, nil +} + +// appendWarning adds the warning unless it is empty, which is how the +// extractors say that nothing went wrong. +func appendWarning(warnings []string, warning string) []string { + if warning == "" { + return warnings + } + return append(warnings, warning) +} + +// fillEmptyLists makes lists encode as [] rather than null, so consumers do +// not need two checks for "none". +func fillEmptyLists(info *Info) { + if info.DeviceFamilies == nil { + info.DeviceFamilies = []string{} + } + if info.Architectures == nil { + info.Architectures = []string{} + } + if info.Permissions == nil { + info.Permissions = []string{} + } + if info.Warnings == nil { + info.Warnings = []string{} + } +} diff --git a/appmeta_test.go b/appmeta_test.go new file mode 100644 index 0000000..8afbdf0 --- /dev/null +++ b/appmeta_test.go @@ -0,0 +1,40 @@ +package appmeta + +import ( + "errors" + "testing" +) + +func TestInputThatIsNotAZipIsUnsupported(t *testing.T) { + _, err := parseBytes([]byte("definitely not a zip file")) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) + } +} + +func TestEmptyInputIsUnsupported(t *testing.T) { + _, err := parseBytes(nil) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) + } +} + +func TestAZipThatIsNeitherAnAPKNorAnIPAIsUnsupported(t *testing.T) { + data := buildZip(t, zipEntry{name: "hello.txt", data: []byte("hi")}) + _, err := parseBytes(data) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) + } +} + +func TestAZipDeclaringMoreEntriesThanTheLimitIsRejectedBeforeReadingThem(t *testing.T) { + data := buildZip(t, + zipEntry{name: "a", data: nil}, + zipEntry{name: "b", data: nil}, + zipEntry{name: "c", data: nil}, + ) + _, err := parseBytes(data, WithLimits(Limits{MaxEntries: 2})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} diff --git a/archive.go b/archive.go new file mode 100644 index 0000000..d8c5536 --- /dev/null +++ b/archive.go @@ -0,0 +1,235 @@ +package appmeta + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" +) + +const ( + eocdLen = 22 + maxZipCommentLen = 65535 + zip64LocatorLen = 20 + zip64EOCDLen = 56 + zip16BitEntryCountMax = 0xFFFF + zip32BitSizeMax = 0xFFFFFFFF + zip32BitOffsetMax = 0xFFFFFFFF +) + +var ( + eocdSignature = []byte("PK\x05\x06") + zip64LocatorSignature = []byte("PK\x06\x07") + zip64EOCDSignature = []byte("PK\x06\x06") +) + +// archive gives bounded access to the entries of a zip. Entry names are only +// ever used as map keys, never as filesystem paths. +type archive struct { + r *failureRecordingReader + size int64 + directory zipDirectory + files map[string]*zip.File + limits Limits + totalRead int64 +} + +// failureRecordingReader remembers the first failure of the input, so that a +// broken transport fails the parse instead of passing for a malformed app. +// Reading past the end is not a failure: hostile offsets cause it too. +type failureRecordingReader struct { + r io.ReaderAt + err error +} + +func (f *failureRecordingReader) ReadAt(p []byte, off int64) (int, error) { + n, err := f.r.ReadAt(p, off) + if err != nil && !errors.Is(err, io.EOF) && f.err == nil { + f.err = err + } + return n, err +} + +// failure returns the first read failure of the input, or nil. +func (f *failureRecordingReader) failure() error { + if f.err == nil { + return nil + } + return fmt.Errorf("appmeta: reading input: %w", f.err) +} + +func openArchive(input io.ReaderAt, size int64, limits Limits) (*archive, error) { + r := &failureRecordingReader{r: input} + a, err := readArchive(r, size, limits) + if failure := r.failure(); failure != nil { + return nil, failure + } + return a, err +} + +func readArchive(r *failureRecordingReader, size int64, limits Limits) (*archive, error) { + dir, err := readZipDirectory(r, size) + if err != nil { + return nil, err + } + // archive/zip allocates per declared entry, so check the count first. + if dir.entries > uint64(limits.MaxEntries) { + return nil, fmt.Errorf("%w: archive declares %d entries, max %d", ErrLimitExceeded, dir.entries, limits.MaxEntries) + } + + zr, err := zip.NewReader(r, size) + // Insecure names are harmless here: they never reach a filesystem. + if err != nil && !errors.Is(err, zip.ErrInsecurePath) { + return nil, fmt.Errorf("%w: %v", ErrUnsupportedFormat, err) + } + // The declared count is only a hint to archive/zip, which reads entries + // until the directory ends. + if len(zr.File) > limits.MaxEntries { + return nil, fmt.Errorf("%w: archive has %d entries, max %d", ErrLimitExceeded, len(zr.File), limits.MaxEntries) + } + + files := make(map[string]*zip.File, len(zr.File)) + for _, f := range zr.File { + // Keep the first of duplicate names; later ones are usually tricks. + if _, dup := files[f.Name]; !dup { + files[f.Name] = f + } + } + return &archive{r: r, size: size, directory: dir, files: files, limits: limits}, nil +} + +// zipDirectory is what the end of central directory record declares. +type zipDirectory struct { + entries uint64 + offset uint64 +} + +// readZipDirectory reads the end of central directory record, following the +// zip64 locator when a 16- or 32-bit field overflows, as archive/zip does. +func readZipDirectory(r io.ReaderAt, size int64) (zipDirectory, error) { + tailLen := min(size, eocdLen+maxZipCommentLen) + if tailLen < eocdLen { + return zipDirectory{}, ErrUnsupportedFormat + } + tail, err := readAt(r, size-tailLen, tailLen) + if err != nil { + return zipDirectory{}, err + } + i := bytes.LastIndex(tail, eocdSignature) + if i < 0 || len(tail)-i < eocdLen { + return zipDirectory{}, ErrUnsupportedFormat + } + dir := zipDirectory{ + entries: uint64(binary.LittleEndian.Uint16(tail[i+10:])), + offset: uint64(binary.LittleEndian.Uint32(tail[i+16:])), + } + directorySize := binary.LittleEndian.Uint32(tail[i+12:]) + if dir.entries != zip16BitEntryCountMax && directorySize != zip32BitSizeMax && dir.offset != zip32BitOffsetMax { + return dir, nil + } + return readZip64Directory(r, size, size-tailLen+int64(i), dir) +} + +// readZip64Directory returns the 32-bit directory when no zip64 locator sits +// right before the end of central directory record at eocdOffset. +func readZip64Directory(r io.ReaderAt, size, eocdOffset int64, dir zipDirectory) (zipDirectory, error) { + locatorOffset := eocdOffset - zip64LocatorLen + if locatorOffset < 0 { + return dir, nil + } + locator, err := readAt(r, locatorOffset, zip64LocatorLen) + if err != nil { + return zipDirectory{}, err + } + if !bytes.Equal(locator[:4], zip64LocatorSignature) { + return dir, nil + } + recordOffset := binary.LittleEndian.Uint64(locator[8:]) + if size < zip64EOCDLen || recordOffset > uint64(size-zip64EOCDLen) { + return zipDirectory{}, fmt.Errorf("%w: zip64 record out of bounds", ErrUnsupportedFormat) + } + record, err := readAt(r, int64(recordOffset), zip64EOCDLen) + if err != nil { + return zipDirectory{}, err + } + if !bytes.Equal(record[:4], zip64EOCDSignature) { + return zipDirectory{}, fmt.Errorf("%w: bad zip64 record", ErrUnsupportedFormat) + } + return zipDirectory{ + entries: binary.LittleEndian.Uint64(record[32:]), + offset: binary.LittleEndian.Uint64(record[48:]), + }, nil +} + +func readAt(r io.ReaderAt, off, n int64) ([]byte, error) { + buf := make([]byte, n) + read, err := r.ReadAt(buf, off) + if read == len(buf) { + return buf, nil + } + if err == nil || errors.Is(err, io.EOF) { + err = io.ErrUnexpectedEOF + } + return nil, err +} + +// readRange returns n bytes that sit outside the zip entries. They count +// towards MaxTotalSize like entry data does. +func (a *archive) readRange(off, n uint64) ([]byte, error) { + if off > uint64(a.size) || n > uint64(a.size)-off { + return nil, io.ErrUnexpectedEOF + } + if int64(n) > a.limits.MaxTotalSize-a.totalRead { + return nil, fmt.Errorf("%w: reading %d bytes at offset %d", ErrLimitExceeded, n, off) + } + a.totalRead += int64(n) + return readAt(a.r, int64(off), int64(n)) +} + +func (a *archive) has(name string) bool { + _, ok := a.files[name] + return ok +} + +// read returns the whole entry, failing if it is larger than MaxEntrySize. +func (a *archive) read(name string) ([]byte, error) { + return a.readEntry(name, a.limits.MaxEntrySize, false) +} + +// readPrefix returns at most the first n bytes of the entry. +func (a *archive) readPrefix(name string, n int64) ([]byte, error) { + return a.readEntry(name, min(n, a.limits.MaxEntrySize), true) +} + +func (a *archive) readEntry(name string, limit int64, truncate bool) ([]byte, error) { + f, ok := a.files[name] + if !ok { + return nil, fmt.Errorf("%s: %w", name, fs.ErrNotExist) + } + if !truncate && f.UncompressedSize64 > uint64(limit) { + return nil, fmt.Errorf("%w: %s is %d bytes, max %d", ErrLimitExceeded, name, f.UncompressedSize64, limit) + } + + budget := min(limit, a.limits.MaxTotalSize-a.totalRead) + rc, err := f.Open() + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + defer func() { _ = rc.Close() }() + // The declared size is not trusted: the limit applies to what inflates. + data, err := io.ReadAll(io.LimitReader(rc, budget+1)) + a.totalRead += int64(len(data)) + if int64(len(data)) > budget { + if truncate && budget == limit { + return data[:limit], nil + } + return nil, fmt.Errorf("%w: reading %s", ErrLimitExceeded, name) + } + if err != nil { + return nil, fmt.Errorf("%s: %w", name, err) + } + return data, nil +} diff --git a/arsc.go b/arsc.go new file mode 100644 index 0000000..50967b5 --- /dev/null +++ b/arsc.go @@ -0,0 +1,271 @@ +package appmeta + +import ( + "fmt" + "strconv" +) + +const ( + tableHeaderLen = 12 + tablePackageIDOffset = 8 + tableTypeHeaderLen = 20 + tableTypeFlagSparse = 0x01 + tableTypeFlagOffset16 = 0x02 + tableEntryFlagComplex = 0x01 + tableEntryFlagCompact = 0x08 + tableEntryHeaderLen = 8 + resValueLen = 8 + noEntry32 = 0xFFFFFFFF + noEntry16 = 0xFFFF + configLanguageOffset = 8 + configDensityOffset = 14 + densityAny = 0xFFFE + densityNone = 0xFFFF + densityMedium = 160 + maxResourceReferenceHop = 8 +) + +// resourceConfig is the part of ResTable_config appmeta cares about. +type resourceConfig struct { + language [2]byte + density uint16 +} + +// resourceTypeChunk is one ResTable_type: the entries of one type for one +// configuration. Offsets and entries stay as slices of the table data. +type resourceTypeChunk struct { + config resourceConfig + flags uint8 + entryCount uint32 + offsets []byte + entries []byte +} + +type resourceValue struct { + config resourceConfig + valueType uint8 + data uint32 +} + +// resourceTable indexes resources.arsc without copying it, so lookups cost +// only the entries they touch. +type resourceTable struct { + values *stringPool + types map[uint32][]resourceTypeChunk +} + +func parseResourceTable(data []byte) (*resourceTable, error) { + root, _, err := nextChunk(data) + if err != nil { + return nil, err + } + if root.typ != chunkTable || root.headerSize < tableHeaderLen { + return nil, fmt.Errorf("%w: not a resource table", errMalformedResource) + } + t := &resourceTable{types: map[uint32][]resourceTypeChunk{}} + rest := root.body() + for len(rest) > 0 { + var c chunk + c, rest, err = nextChunk(rest) + if err != nil { + return nil, err + } + switch c.typ { + case chunkStringPool: + if t.values == nil { + if t.values, err = parseStringPool(c); err != nil { + return nil, err + } + } + case chunkTablePackage: + if err := t.addPackage(c); err != nil { + return nil, err + } + } + } + return t, nil +} + +func (t *resourceTable) addPackage(pkg chunk) error { + if pkg.headerSize < tablePackageIDOffset+4 { + return fmt.Errorf("%w: short package header", errMalformedResource) + } + id := le.Uint32(pkg.data[tablePackageIDOffset:]) + rest := pkg.body() + for len(rest) > 0 { + c, next, err := nextChunk(rest) + if err != nil { + return err + } + rest = next + if c.typ != chunkTableType { + continue + } + typeChunk, typeID, err := parseTypeChunk(c) + if err != nil { + return err + } + key := id<<8 | uint32(typeID) + t.types[key] = append(t.types[key], typeChunk) + } + return nil +} + +func parseTypeChunk(c chunk) (resourceTypeChunk, uint8, error) { + if c.headerSize < tableTypeHeaderLen { + return resourceTypeChunk{}, 0, fmt.Errorf("%w: short type header", errMalformedResource) + } + d := c.data + typeID := d[8] + tc := resourceTypeChunk{flags: d[9], entryCount: le.Uint32(d[12:])} + entriesStart := uint64(le.Uint32(d[16:])) + if entriesStart < uint64(c.headerSize) || entriesStart > uint64(len(d)) { + return resourceTypeChunk{}, 0, fmt.Errorf("%w: type entries out of bounds", errMalformedResource) + } + tc.offsets = d[c.headerSize:entriesStart] + tc.entries = d[entriesStart:] + if uint64(tc.entryCount)*uint64(tc.offsetWidth()) > uint64(len(tc.offsets)) { + return resourceTypeChunk{}, 0, fmt.Errorf("%w: type declares %d entries", errMalformedResource, tc.entryCount) + } + + config := d[tableTypeHeaderLen:c.headerSize] + if len(config) >= configLanguageOffset+2 { + copy(tc.config.language[:], config[configLanguageOffset:]) + } + if len(config) >= configDensityOffset+2 { + tc.config.density = le.Uint16(config[configDensityOffset:]) + } + return tc, typeID, nil +} + +func (tc resourceTypeChunk) offsetWidth() int { + if tc.flags&tableTypeFlagOffset16 != 0 && tc.flags&tableTypeFlagSparse == 0 { + return 2 + } + return 4 +} + +// entryOffset finds where an entry starts in tc.entries. +func (tc resourceTypeChunk) entryOffset(index uint16) (uint64, bool) { + switch { + case tc.flags&tableTypeFlagSparse != 0: + // Sparse tables list (index, offset/4) pairs. + for i := range tc.entryCount { + pair := tc.offsets[i*4:] + if le.Uint16(pair) == index { + return uint64(le.Uint16(pair[2:])) * 4, true + } + } + return 0, false + case uint32(index) >= tc.entryCount: + return 0, false + case tc.flags&tableTypeFlagOffset16 != 0: + off := le.Uint16(tc.offsets[int(index)*2:]) + return uint64(off) * 4, off != noEntry16 + default: + off := le.Uint32(tc.offsets[int(index)*4:]) + return uint64(off), off != noEntry32 + } +} + +func (tc resourceTypeChunk) value(index uint16) (resourceValue, bool) { + off, ok := tc.entryOffset(index) + if !ok || off+tableEntryHeaderLen > uint64(len(tc.entries)) { + return resourceValue{}, false + } + entry := tc.entries[off:] + size := uint64(le.Uint16(entry)) + flags := le.Uint16(entry[2:]) + if flags&tableEntryFlagCompact != 0 { + return resourceValue{config: tc.config, valueType: uint8(flags >> 8), data: le.Uint32(entry[4:])}, true + } + // Complex entries are styles and arrays; nothing appmeta reads. + if flags&tableEntryFlagComplex != 0 || size+resValueLen > uint64(len(entry)) { + return resourceValue{}, false + } + v := entry[size:] + return resourceValue{config: tc.config, valueType: v[3], data: le.Uint32(v[4:])}, true +} + +// lookup returns the value of a resource in every configuration that has one. +func (t *resourceTable) lookup(resID uint32) []resourceValue { + var values []resourceValue + for _, tc := range t.types[resID>>16] { + if v, ok := tc.value(uint16(resID)); ok { + values = append(values, v) + } + } + return values +} + +// resolveText resolves a reference to text in the default configuration, +// falling back to English, then to any configuration. +func (t *resourceTable) resolveText(resID uint32) (string, bool) { + for hop := 0; hop < maxResourceReferenceHop; hop++ { + v, ok := preferredLocaleValue(t.lookup(resID)) + if !ok { + return "", false + } + switch v.valueType { + case resValueTypeReference, resValueTypeDynamicRef: + resID = v.data + continue + case resValueTypeString: + s, err := t.values.get(v.data) + return s, err == nil + } + return xmlAttr{valueType: v.valueType, data: v.data}.text(), true + } + return "", false +} + +func preferredLocaleValue(values []resourceValue) (resourceValue, bool) { + for _, want := range [][2]byte{{}, {'e', 'n'}} { + for _, v := range values { + if v.config.language == want { + return v, true + } + } + } + if len(values) > 0 { + return values[0], true + } + return resourceValue{}, false +} + +// resourceFile is a file path a resource resolves to in one configuration. +type resourceFile struct { + path string + density uint16 +} + +// resolveFiles follows a drawable or mipmap reference to the file paths it +// names in each configuration. +func (t *resourceTable) resolveFiles(resID uint32) []resourceFile { + var files []resourceFile + pending := []uint32{resID} + seen := map[uint32]bool{} + for len(pending) > 0 && len(seen) < maxResourceReferenceHop { + id := pending[0] + pending = pending[1:] + if seen[id] { + continue + } + seen[id] = true + for _, v := range t.lookup(id) { + switch v.valueType { + case resValueTypeReference, resValueTypeDynamicRef: + pending = append(pending, v.data) + case resValueTypeString: + if path, err := t.values.get(v.data); err == nil { + files = append(files, resourceFile{path: path, density: v.config.density}) + } + } + } + } + return files +} + +func formatResID(id uint32) string { + return "@0x" + strconv.FormatUint(uint64(id), 16) +} diff --git a/arsc_test.go b/arsc_test.go new file mode 100644 index 0000000..4f711d4 --- /dev/null +++ b/arsc_test.go @@ -0,0 +1,127 @@ +package appmeta + +import ( + "strings" + "testing" +) + +func TestTheLabelResolvesToTheDefaultLocaleString(t *testing.T) { + info := mustParse(t, buildAcmeShopAPK(t)) + assertEqual(t, "name", info.Name, "Acme Shop") +} + +func TestTheLabelFallsBackToEnglishWhenThereIsNoDefaultLocale(t *testing.T) { + resources := []resEntry{{typeName: "string", entry: 0, values: []resTestValue{ + stringValue("fr", "Bonjour"), + stringValue("en", "Hello"), + }}} + info := mustParse(t, buildAPKWithLabelResources(t, resources, denseOffsets)) + assertEqual(t, "name", info.Name, "Hello") +} + +func TestALabelThatReferencesAnotherStringResolves(t *testing.T) { + resources := []resEntry{ + {typeName: "string", entry: 0, values: []resTestValue{referenceValue(resID("string", 1))}}, + {typeName: "string", entry: 1, values: []resTestValue{stringValue("", "Indirect")}}, + } + info := mustParse(t, buildAPKWithLabelResources(t, resources, denseOffsets)) + assertEqual(t, "name", info.Name, "Indirect") +} + +func TestALabelReferenceLoopEndsWithAWarning(t *testing.T) { + resources := []resEntry{{typeName: "string", entry: 0, values: []resTestValue{referenceValue(resID("string", 0))}}} + info := mustParse(t, buildAPKWithLabelResources(t, resources, denseOffsets)) + assertEqual(t, "name", info.Name, "com.acme.label") + assertWarningMentions(t, info, "android:label") +} + +func TestSparseAndCompactResourceTablesResolveLikeDenseOnes(t *testing.T) { + resources := []resEntry{ + {typeName: "string", entry: 0, values: []resTestValue{stringValue("", "First")}}, + {typeName: "string", entry: 3, values: []resTestValue{stringValue("", "Fourth")}}, + } + for _, layout := range []resTableLayout{denseOffsets, sparseOffsets, offset16WithCompactEntries} { + data := buildAPKWithManifest(t, + manifestElement("com.acme.layout", attrs(androidReference("label", attrLabel, resID("string", 3)))), + zipEntry{name: androidResourcesPath, data: encodeResourceTableLayout(resources, layout)}, + ) + info := mustParse(t, data) + assertEqual(t, "name", info.Name, "Fourth") + } +} + +func TestAMissingResourceTableFallsBackToThePackageNameWithAWarning(t *testing.T) { + data := buildAPKWithManifest(t, acmeShopManifest()) + info := mustParse(t, data) + assertEqual(t, "name", info.Name, "com.acme.shop") + assertWarningMentions(t, info, "resources.arsc") + if info.Icon != nil { + t.Error("want no icon") + } +} + +func TestTheHighestDensityRasterIconIsChosen(t *testing.T) { + info := mustParse(t, buildAcmeShopAPK(t)) + assertEqual(t, "icon width", info.Icon.Width, 192) + assertEqual(t, "icon color", iconCenterColor(t, info.Icon), acmeRed) + assertEqual(t, "content type", info.Icon.ContentType, "image/png") + if len(info.Warnings) != 0 { + t.Errorf("want no warnings, got %q", info.Warnings) + } +} + +func TestAnAdaptiveOnlyIconUsesItsForegroundLayerWithAWarning(t *testing.T) { + info := mustParse(t, buildAdaptiveIconAPK(t)) + assertEqual(t, "icon color", iconCenterColor(t, info.Icon), acmeBlue) + assertWarningMentions(t, info, "foreground layer only") +} + +func TestAVectorOnlyIconLeavesTheIconEmptyWithAWarning(t *testing.T) { + vector := encodeAXML(element("vector", nil, element("path", nil))) + info := mustParse(t, buildAPKWithIconFile(t, "res/drawable/ic_launcher.xml", vector)) + if info.Icon != nil { + t.Fatal("want no icon") + } + assertWarningMentions(t, info, "vector drawables are not rendered") +} + +func TestIconsLargerThan512PixelsAreScaledDownKeepingTheirAspectRatio(t *testing.T) { + info := mustParse(t, buildAPKWithIconFile(t, "res/mipmap/ic.png", solidPNG(t, 1024, 512, acmeGreen))) + assertEqual(t, "width", info.Icon.Width, 512) + assertEqual(t, "height", info.Icon.Height, 256) + assertEqual(t, "icon color", iconCenterColor(t, info.Icon), acmeGreen) +} + +func TestAnIconOverThePixelLimitIsSkippedWithAWarning(t *testing.T) { + data := buildAPKWithIconFile(t, "res/mipmap/ic.png", solidPNG(t, 64, 64, acmeGreen)) + info := mustParse(t, data, WithLimits(Limits{MaxIconPixels: 32 * 32})) + if info.Icon != nil { + t.Fatal("want no icon") + } + assertWarningMentions(t, info, "limit exceeded") +} + +func TestACorruptIconIsSkippedWithAWarning(t *testing.T) { + info := mustParse(t, buildAPKWithIconFile(t, "res/mipmap/ic.png", []byte("\x89PNG\r\n\x1a\ngarbage"))) + if info.Icon != nil { + t.Fatal("want no icon") + } + assertWarningMentions(t, info, "icon not extracted") +} + +func buildAPKWithLabelResources(t testing.TB, resources []resEntry, layout resTableLayout) []byte { + return buildAPKWithManifest(t, + manifestElement("com.acme.label", attrs(androidReference("label", attrLabel, resID("string", 0)))), + zipEntry{name: androidResourcesPath, data: encodeResourceTableLayout(resources, layout)}, + ) +} + +func assertWarningMentions(t *testing.T, info *Info, text string) { + t.Helper() + for _, w := range info.Warnings { + if strings.Contains(w, text) { + return + } + } + t.Errorf("no warning mentions %q; warnings: %q", text, info.Warnings) +} diff --git a/axml.go b/axml.go new file mode 100644 index 0000000..210dc55 --- /dev/null +++ b/axml.go @@ -0,0 +1,167 @@ +package appmeta + +import ( + "fmt" + "slices" + "strconv" +) + +const ( + xmlNodeHeaderLen = 16 + xmlStartElementExtLen = 20 + xmlAttributeLen = 20 + noStringIndex = 0xFFFFFFFF +) + +// xmlAttr is one attribute of a binary XML element. Android attributes are +// identified by resID because obfuscators strip or rename their names. +type xmlAttr struct { + name string + resID uint32 + raw string + valueType uint8 + data uint32 +} + +// xmlVisitor is called for each start element with the element names from +// the root down to it. +type xmlVisitor func(path []string, attrs []xmlAttr) + +// parseAXML walks Android binary XML iteratively, so nesting depth costs +// memory only up to maxDepth. +func parseAXML(data []byte, maxDepth int, visit xmlVisitor) error { + root, _, err := nextChunk(data) + if err != nil { + return err + } + if root.typ != chunkXML { + return fmt.Errorf("%w: not binary xml", errMalformedResource) + } + + var pool *stringPool + var resourceMap []byte + var path []string + rest := root.body() + for len(rest) > 0 { + var c chunk + c, rest, err = nextChunk(rest) + if err != nil { + return err + } + switch c.typ { + case chunkStringPool: + if pool != nil { + return fmt.Errorf("%w: second string pool", errMalformedResource) + } + if pool, err = parseStringPool(c); err != nil { + return err + } + case chunkXMLResourceMap: + resourceMap = c.body() + case chunkXMLStartElement: + if len(path) >= maxDepth { + return fmt.Errorf("%w: xml nested deeper than %d", ErrLimitExceeded, maxDepth) + } + name, attrs, err := parseStartElement(c, pool, resourceMap) + if err != nil { + return err + } + path = append(path, name) + visit(path, attrs) + case chunkXMLEndElement: + if len(path) == 0 { + return fmt.Errorf("%w: unbalanced end element", errMalformedResource) + } + path = path[:len(path)-1] + } + } + return nil +} + +func parseStartElement(c chunk, pool *stringPool, resourceMap []byte) (string, []xmlAttr, error) { + if c.headerSize < xmlNodeHeaderLen { + return "", nil, fmt.Errorf("%w: short xml node header", errMalformedResource) + } + ext := c.body() + if len(ext) < xmlStartElementExtLen { + return "", nil, fmt.Errorf("%w: short start element", errMalformedResource) + } + name, err := pool.get(le.Uint32(ext[4:])) + if err != nil { + return "", nil, err + } + start := uint64(le.Uint16(ext[8:])) + size := uint64(le.Uint16(ext[10:])) + count := uint64(le.Uint16(ext[12:])) + if size < xmlAttributeLen || start+size*count > uint64(len(ext)) { + return "", nil, fmt.Errorf("%w: attributes overflow element %q", errMalformedResource, name) + } + + attrs := make([]xmlAttr, 0, count) + for i := range count { + a := ext[start+i*size:] + nameIndex := le.Uint32(a[4:]) + attr := xmlAttr{ + valueType: a[15], + data: le.Uint32(a[16:]), + } + if attr.name, err = pool.get(nameIndex); err != nil { + return "", nil, err + } + if uint64(nameIndex)*4+4 <= uint64(len(resourceMap)) { + attr.resID = le.Uint32(resourceMap[nameIndex*4:]) + } + if rawIndex := le.Uint32(a[8:]); rawIndex != noStringIndex { + if attr.raw, err = pool.get(rawIndex); err != nil { + return "", nil, err + } + } else if attr.valueType == resValueTypeString { + if attr.raw, err = pool.get(attr.data); err != nil { + return "", nil, err + } + } + attrs = append(attrs, attr) + } + return name, attrs, nil +} + +// isReference reports whether the value points at a resource that +// resources.arsc must resolve. +func (a xmlAttr) isReference() bool { + return (a.valueType == resValueTypeReference || a.valueType == resValueTypeDynamicRef) && a.data != 0 +} + +// text is the attribute as a string; references have no text. +func (a xmlAttr) text() string { + switch a.valueType { + case resValueTypeIntDec: + return strconv.FormatInt(int64(int32(a.data)), 10) + case resValueTypeIntHex: + return "0x" + strconv.FormatUint(uint64(a.data), 16) + case resValueTypeBool: + return strconv.FormatBool(a.data != 0) + case resValueTypeReference, resValueTypeDynamicRef: + return "" + } + return a.raw +} + +// findAttr returns the attribute with the given Android resource id, or with +// the given name when the file has no resource map entry for it. +func findAttr(attrs []xmlAttr, resID uint32, name string) (xmlAttr, bool) { + for _, a := range attrs { + if a.resID != 0 && a.resID == resID { + return a, true + } + } + for _, a := range attrs { + if a.resID == 0 && a.name == name { + return a, true + } + } + return xmlAttr{}, false +} + +func pathIs(path []string, want ...string) bool { + return slices.Equal(path, want) +} diff --git a/cgbi.go b/cgbi.go new file mode 100644 index 0000000..34dd9f4 --- /dev/null +++ b/cgbi.go @@ -0,0 +1,172 @@ +package appmeta + +import ( + "bytes" + "compress/flate" + "encoding/binary" + "errors" + "fmt" + "image" + "image/color" + "io" +) + +// Apple's CgBI PNG variant (Xcode's "pngcrush -iphone") differs from PNG in +// three ways: a CgBI chunk comes first, IDAT holds raw deflate without a zlib +// header, and pixels are premultiplied BGRA. Go's image/png rejects it. + +const ( + pngChunkOverhead = 12 + pngIHDRLen = 13 + cgbiBytesPerPx = 4 + pngColorRGBA = 6 +) + +var errMalformedCgBI = fmt.Errorf("%w: CgBI png", ErrMalformed) + +func isCgBI(data []byte) bool { + rest := data[len(pngSignature):] + return len(rest) >= 8 && string(rest[4:8]) == "CgBI" +} + +type cgbiImage struct { + width, height int + idat []byte +} + +func parseCgBI(data []byte) (cgbiImage, error) { + var img cgbiImage + var ihdr []byte + var idat [][]byte + rest := data[len(pngSignature):] + for len(rest) >= pngChunkOverhead { + n := uint64(binary.BigEndian.Uint32(rest)) + if n > uint64(len(rest)-pngChunkOverhead) { + return img, fmt.Errorf("%w: chunk overflows", errMalformedCgBI) + } + typ, body := string(rest[4:8]), rest[8:8+n] + rest = rest[pngChunkOverhead+n:] + switch typ { + case "IHDR": + ihdr = body + case "IDAT": + idat = append(idat, body) + case "IEND": + rest = nil + } + } + if len(ihdr) != pngIHDRLen { + return img, fmt.Errorf("%w: missing IHDR", errMalformedCgBI) + } + bitDepth, colorType, interlace := ihdr[8], ihdr[9], ihdr[12] + if bitDepth != 8 || colorType != pngColorRGBA || interlace != 0 { + return img, fmt.Errorf("%w: unsupported layout (depth %d, color %d, interlace %d)", errMalformedCgBI, bitDepth, colorType, interlace) + } + img.width = int(binary.BigEndian.Uint32(ihdr)) + img.height = int(binary.BigEndian.Uint32(ihdr[4:])) + img.idat = bytes.Join(idat, nil) + return img, nil +} + +func decodeCgBIConfig(data []byte) (image.Config, error) { + img, err := parseCgBI(data) + if err != nil { + return image.Config{}, err + } + return image.Config{ColorModel: color.NRGBAModel, Width: img.width, Height: img.height}, nil +} + +// decodeCgBI must only be called after the pixel count was checked against +// the limits, since it allocates width*height*4 bytes. +func decodeCgBI(data []byte) (image.Image, error) { + src, err := parseCgBI(data) + if err != nil { + return nil, err + } + stride := src.width * cgbiBytesPerPx + want := int64(src.height) * int64(stride+1) + raw, err := io.ReadAll(io.LimitReader(flate.NewReader(bytes.NewReader(src.idat)), want+1)) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) { + return nil, fmt.Errorf("%w: %v", errMalformedCgBI, err) + } + if int64(len(raw)) < want { + return nil, fmt.Errorf("%w: short pixel data", errMalformedCgBI) + } + + img := image.NewNRGBA(image.Rect(0, 0, src.width, src.height)) + prev := make([]byte, stride) + for y := range src.height { + row := raw[y*(stride+1) : (y+1)*(stride+1)] + if err := unfilterRow(row[0], row[1:], prev); err != nil { + return nil, err + } + out := img.Pix[y*img.Stride:] + for x := 0; x < stride; x += cgbiBytesPerPx { + b, g, r, a := row[1+x], row[2+x], row[3+x], row[4+x] + out[x], out[x+1], out[x+2], out[x+3] = unpremultiply(r, a), unpremultiply(g, a), unpremultiply(b, a), a + } + prev = row[1:] + } + return img, nil +} + +func unpremultiply(c, a uint8) uint8 { + if a == 0 { + return 0 + } + return uint8(min(255, (uint32(c)*255+uint32(a)/2)/uint32(a))) +} + +// unfilterRow reverses a PNG row filter in place (PNG spec section 9). +func unfilterRow(filter byte, row, prev []byte) error { + const bpp = cgbiBytesPerPx + switch filter { + case 0: + case 1: + for i := bpp; i < len(row); i++ { + row[i] += row[i-bpp] + } + case 2: + for i := range row { + row[i] += prev[i] + } + case 3: + for i := range row { + var left byte + if i >= bpp { + left = row[i-bpp] + } + row[i] += byte((uint16(left) + uint16(prev[i])) / 2) + } + case 4: + for i := range row { + var left, upLeft byte + if i >= bpp { + left, upLeft = row[i-bpp], prev[i-bpp] + } + row[i] += paeth(left, prev[i], upLeft) + } + default: + return fmt.Errorf("%w: unknown filter %d", errMalformedCgBI, filter) + } + return nil +} + +func paeth(a, b, c byte) byte { + p := int(a) + int(b) - int(c) + pa, pb, pc := abs(p-int(a)), abs(p-int(b)), abs(p-int(c)) + switch { + case pa <= pb && pa <= pc: + return a + case pb <= pc: + return b + } + return c +} + +func abs(x int) int { + if x < 0 { + return -x + } + return x +} diff --git a/cmd/appmeta/main.go b/cmd/appmeta/main.go new file mode 100644 index 0000000..d328068 --- /dev/null +++ b/cmd/appmeta/main.go @@ -0,0 +1,78 @@ +// Command appmeta prints the metadata of an .apk or .ipa as JSON. +package main + +import ( + "encoding/json" + "errors" + "io" + "os" + + "github.com/spf13/cobra" + + "github.com/mobile-next/appmeta" +) + +type options struct { + iconPath string + noIcon bool +} + +func main() { + cmd := newRootCommand(os.Stdout) + if err := cmd.Execute(); err != nil { + // Nothing is left to report to if stdout itself is what failed. + _ = printJSON(os.Stdout, map[string]string{"error": err.Error()}) + os.Exit(1) + } +} + +func newRootCommand(out io.Writer) *cobra.Command { + var opts options + cmd := &cobra.Command{ + Use: "appmeta [flags] ", + Short: "Print the metadata of an Android or iOS app as JSON", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + return run(out, args[0], opts) + }, + } + cmd.Flags().StringVar(&opts.iconPath, "icon", "", "also write the icon PNG to this file") + cmd.Flags().BoolVar(&opts.noIcon, "no-icon", false, "omit the base64 icon from the JSON") + return cmd +} + +func run(out io.Writer, path string, opts options) error { + f, err := os.Open(path) + if err != nil { + return err + } + defer func() { _ = f.Close() }() + stat, err := f.Stat() + if err != nil { + return err + } + info, err := appmeta.Parse(f, stat.Size()) + if err != nil { + return err + } + if opts.iconPath != "" { + if info.Icon == nil { + return errors.New("the app has no extractable icon") + } + if err := os.WriteFile(opts.iconPath, info.Icon.PNG, 0o644); err != nil { + return err + } + } + if opts.noIcon && info.Icon != nil { + info.Icon.PNG = nil + } + return printJSON(out, info) +} + +func printJSON(out io.Writer, v any) error { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + return enc.Encode(v) +} diff --git a/cmd/appmeta/main_test.go b/cmd/appmeta/main_test.go new file mode 100644 index 0000000..ee1a827 --- /dev/null +++ b/cmd/appmeta/main_test.go @@ -0,0 +1,109 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + + "github.com/spf13/pflag" + + "github.com/mobile-next/appmeta" +) + +func TestAFileThatIsNotAnAppIsReportedAsUnsupported(t *testing.T) { + path := filepath.Join(t.TempDir(), "notes.zip") + writeZipWithOneFile(t, path, "notes.txt") + err := runCommand(t, path) + if !errors.Is(err, appmeta.ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) + } +} + +func TestAMissingFileIsAnError(t *testing.T) { + if err := runCommand(t, filepath.Join(t.TempDir(), "missing.apk")); err == nil { + t.Fatal("want an error") + } +} + +func TestLongFlagsAreAccepted(t *testing.T) { + path := filepath.Join(t.TempDir(), "notes.zip") + writeZipWithOneFile(t, path, "notes.txt") + err := runCommand(t, "--no-icon", "--icon", filepath.Join(t.TempDir(), "icon.png"), path) + if !errors.Is(err, appmeta.ErrUnsupportedFormat) { + t.Fatalf("flags were not parsed: %v", err) + } +} + +func TestExactlyOnePathIsRequired(t *testing.T) { + if err := runCommand(t); err == nil { + t.Fatal("want an error without a path") + } + if err := runCommand(t, "a.apk", "b.apk"); err == nil { + t.Fatal("want an error with two paths") + } +} + +func TestErrorsArePrintedAsJSON(t *testing.T) { + var out bytes.Buffer + if err := printJSON(&out, map[string]string{"error": "boom"}); err != nil { + t.Fatal(err) + } + var got map[string]string + if err := json.Unmarshal(out.Bytes(), &got); err != nil || got["error"] != "boom" { + t.Fatalf("got %q, %v", out.String(), err) + } +} + +func TestAFailedWriteOfTheOutputIsAnError(t *testing.T) { + err := printJSON(closedPipe{}, map[string]string{"name": "Acme"}) + if !errors.Is(err, errClosedPipe) { + t.Fatalf("got %v, want the write error", err) + } +} + +var errClosedPipe = errors.New("closed pipe") + +type closedPipe struct{} + +func (closedPipe) Write([]byte) (int, error) { + return 0, errClosedPipe +} + +func runCommand(t *testing.T, args ...string) error { + t.Helper() + var out bytes.Buffer + cmd := newRootCommand(&out) + cmd.SetArgs(args) + cmd.SetOut(&out) + cmd.SetErr(&out) + return cmd.Execute() +} + +func writeZipWithOneFile(t *testing.T, path, name string) { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + if _, err := w.Create(name); err != nil { + t.Fatal(err) + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.Bytes(), 0o644); err != nil { + t.Fatal(err) + } +} + +func TestThereAreNoShortFlagsExceptHelp(t *testing.T) { + cmd := newRootCommand(&bytes.Buffer{}) + cmd.InitDefaultHelpFlag() + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if f.Shorthand != "" && f.Name != "help" { + t.Errorf("--%s has short form -%s", f.Name, f.Shorthand) + } + }) +} diff --git a/context_test.go b/context_test.go new file mode 100644 index 0000000..eaf1b1d --- /dev/null +++ b/context_test.go @@ -0,0 +1,68 @@ +package appmeta + +import ( + "bytes" + "context" + "errors" + "testing" + "time" +) + +func TestAnAlreadyCancelledContextIsReportedAsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + data := buildMinimalAPK(t) + _, err := ParseContext(ctx, bytes.NewReader(data), int64(len(data))) + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want context.Canceled", err) + } +} + +func TestABlockingReaderIsAbandonedWhenTheDeadlinePasses(t *testing.T) { + unblock := make(chan struct{}) + defer close(unblock) + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + + start := time.Now() + _, err := ParseContext(ctx, blockingReader{unblock: unblock}, 1<<20) + if !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("got %v, want context.DeadlineExceeded", err) + } + if elapsed := time.Since(start); elapsed > time.Second { + t.Fatalf("returned after %v", elapsed) + } +} + +func TestReadsStopOnceTheContextIsCancelled(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + data := buildAcmeShopAPK(t) + reader := cancelAfterReads{r: bytes.NewReader(data), remaining: 3, cancel: cancel} + _, err := ParseContext(ctx, &reader, int64(len(data))) + if !errors.Is(err, context.Canceled) { + t.Fatalf("got %v, want context.Canceled", err) + } +} + +type blockingReader struct { + unblock chan struct{} +} + +func (b blockingReader) ReadAt([]byte, int64) (int, error) { + <-b.unblock + return 0, errors.New("unblocked") +} + +type cancelAfterReads struct { + r *bytes.Reader + remaining int + cancel context.CancelFunc +} + +func (c *cancelAfterReads) ReadAt(p []byte, off int64) (int, error) { + c.remaining-- + if c.remaining == 0 { + c.cancel() + } + return c.r.ReadAt(p, off) +} diff --git a/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..4375418 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,63 @@ +# Decisions + +## Library survey (2026-09-29) + +| Need | Candidate | Licence | Maintenance | Decision | +|------|-----------|---------|-------------|----------| +| APK binary XML + resources.arsc | `github.com/shogo82148/androidbinary` v1.0.6 | MIT | active (pushed 2026-09) | **Not used.** Allocates from header-declared counts before checking them against the input: a 36-byte AXML input declaring 2^27 strings allocated 1 GiB, and a count of 2^32 would ask for ~32 GiB (an unrecoverable OOM, not a panic). It also ignores `binary.Read` errors in `readTableType`, and its `GetString` panics by design. Fixing that is a rewrite of its reader, so appmeta has its own AXML/ARSC reader (`axml.go`, `arsc.go`, `resource_chunk.go`). It reads only what the manifest, label and icon need, with every length checked against the bytes present. Worth upstreaming the fixes later. | +| APK parsing | `github.com/avast/apkparser` | LGPL-3.0 | active | Rejected: LGPL is awkward for static Go binaries under Apache-2.0. | +| plist (binary + XML) | `howett.net/plist` v1.0.1 | BSD-2-Clause + BSD-3-Clause (Go authors) | stable, low activity | **Used, wrapped.** Its binary parser bounds counts against the file size and detects cycles. It survived fuzzing. It recurses once per nesting level, though, and falls back from XML to its text parser, so `plist.go` checks the depth first without recursion (bplist object graph, XML tokens, text brackets). | +| APK signing certificate | (same pkcs7 libraries) | — | — | Own bounded reader (`apksign.go`). It reads the APK Signing Block (v3.1/v3/v2, uint32 length-prefixed, every length checked), else the v1 `META-INF/*.RSA`/`.DSA`/`.EC` PKCS#7, walked with `encoding/asn1`. The certificate is parsed with `crypto/x509`. It is only used to tell the Android debug key from release keys and is not verified. | +| CMS (`embedded.mobileprovision`) | `go.mozilla.org/pkcs7`, `github.com/smallstep/pkcs7` | MIT | mozilla archived; smallstep active | **Not used.** appmeta does not verify the signature (the device does), so it finds the XML plist inside the DER blob directly. That needs no dependency and no ASN.1 parsing of hostile data. | +| Mach-O | stdlib `debug/macho`, `github.com/blacktop/go-macho` (MIT) | BSD-3 / MIT | active | **Not used.** Both expect random access to the whole file, and `debug/macho` reads the symbol table eagerly. appmeta only has a decompressed prefix of a zip entry and needs just the fat header and the load commands, so `macho.go` reads those itself (~150 lines). | +| WebP decode, resizing | `golang.org/x/image` (`webp`, `draw`) | BSD-3-Clause | Go team | **Used.** | +| CgBI PNG | none found maintained | — | — | Own implementation (`cgbi.go`): chunk walk, raw inflate bounded by width×height, PNG unfilter, BGRA→RGBA, un-premultiply. | +| CLI flags | `github.com/spf13/cobra` | Apache-2.0 | active | **Used in `cmd/appmeta` only**, matching the backend CLI; the library does not import it. | +| JSON Schema validation (tests only) | `github.com/santhosh-tekuri/jsonschema/v6` | Apache-2.0 | active | **Used in tests** so the golden outputs are checked against `schema/appmeta.schema.json`. | + +## Fixtures are synthetic + +No Android SDK or Xcode is assumed. The fixture apps are generated in Go +test helpers (`fixtures_*_test.go`): the binary AXML and resources.arsc +encoders, binary and XML plists, CgBI PNGs made from Go-encoded PNGs, a +lossless WebP writer, Mach-O headers, and a CMS wrapper for the profile. No +third-party binary is checked in. Golden outputs live in `testdata/golden`. +They pin everything except the icon bytes and sha256, which depend on the Go +version's PNG encoder. Separate tests decode the icon pixels instead. + +## Limits + +The `Limits` fields have safe defaults and can be overridden: + +- **MaxEntries** (200k): read from the end-of-central-directory record, + including zip64, before `archive/zip` allocates per entry. +- **MaxEntrySize** (64 MiB): caps the bytes that actually inflate, not the + declared size. +- **MaxTotalSize** (256 MiB): caps inflated bytes across all entries read. +- **MaxDepth** (64): caps AXML element nesting and plist nesting. +- **MaxIconPixels** (4096²): checked from the image header before decoding. + +There is no separate decompression-ratio limit. The absolute caps above +already bound the memory and CPU a zip bomb can cost (see +`TestAZipBombManifestIsRejectedWithoutInflatingIt`). + +Other bounds: string pools decode lazily and stop after decoding 4× their own +size; ARSC reference chains stop after 8 hops; at most 32 iOS icon names and +candidate files are considered; Mach-O reads a 1 MiB prefix and at most 32 +fat slices. + +`Parse` recovers panics into errors. The fuzz targets call the inner parsers, +so a panic still fails fuzzing. + +`ParseContext` checks its context before every read from the input and +returns as soon as the context is done, even if a read is blocked. The +parsing goroutine is left to exit once that read returns. `Parse` is +`ParseContext` with `context.Background()`. + +## Toolchain + +`go.mod` pins `toolchain go1.26.6`. Go 1.26.4's `encoding/xml` has +GO-2026-6088 (no recursion depth guard), which govulncheck reports because +plist decoding reaches `xml.Decoder.Token`. appmeta's own depth pre-check +already limits the nesting, but the pin keeps `make vulncheck` clean. Modules +that depend on appmeta ignore the toolchain line. diff --git a/fixtures_apk_test.go b/fixtures_apk_test.go new file mode 100644 index 0000000..f714a91 --- /dev/null +++ b/fixtures_apk_test.go @@ -0,0 +1,131 @@ +package appmeta + +import "testing" + +func buildAPKWithManifest(t testing.TB, manifest xmlNode, extra ...zipEntry) []byte { + t.Helper() + entries := append([]zipEntry{{name: androidManifestPath, data: encodeAXML(manifest)}}, extra...) + return buildZip(t, entries...) +} + +func manifestElement(packageName string, applicationAttrs []xmlTestAttr, children ...xmlNode) xmlNode { + all := append([]xmlNode{ + element("uses-sdk", attrs( + androidInt("minSdkVersion", attrMinSDKVersion, 26), + androidInt("targetSdkVersion", attrTargetSDKVersion, 35), + )), + }, children...) + all = append(all, element("application", applicationAttrs)) + return element("manifest", attrs( + androidInt("versionCode", attrVersionCode, 4201), + androidString("versionName", attrVersionName, "4.2.0"), + plainString("package", packageName), + ), all...) +} + +func usesPermission(name string) xmlNode { + return element("uses-permission", attrs(androidString("name", attrName, name))) +} + +func usesFeature(name string, required bool) xmlNode { + return element("uses-feature", attrs( + androidString("name", attrName, name), + androidBool("required", attrRequired, required), + )) +} + +func buildMinimalAPK(t testing.TB) []byte { + return buildAPKWithManifest(t, manifestElement("com.acme.minimal", attrs( + androidString("label", attrLabel, "Minimal"), + ))) +} + +const ( + densityMdpi = 160 + densityXxxhdpi = 640 +) + +func acmeShopResources() []resEntry { + return []resEntry{ + {typeName: "string", entry: 0, values: []resTestValue{ + stringValue("", "Acme Shop"), + stringValue("fr", "Boutique Acme"), + }}, + {typeName: "mipmap", entry: 0, values: []resTestValue{ + fileAtDensity(densityMdpi, "res/mipmap-mdpi/ic_launcher.png"), + fileAtDensity(densityXxxhdpi, "res/mipmap-xxxhdpi/ic_launcher.webp"), + fileAtDensity(densityAny, "res/mipmap-anydpi-v26/ic_launcher.xml"), + }}, + {typeName: "drawable", entry: 0, values: []resTestValue{ + fileAtDensity(0, "res/drawable/ic_launcher_foreground.png"), + }}, + } +} + +func adaptiveIconXML() []byte { + return encodeAXML(element("adaptive-icon", nil, + element("background", attrs(androidReference("drawable", attrDrawable, resID("drawable", 1)))), + element("foreground", attrs(androidReference("drawable", attrDrawable, resID("drawable", 0)))), + )) +} + +func acmeShopManifest() xmlNode { + return manifestElement("com.acme.shop", attrs( + androidReference("label", attrLabel, resID("string", 0)), + androidReference("icon", attrIcon, resID("mipmap", 0)), + ), + usesPermission("android.permission.CAMERA"), + usesPermission("android.permission.INTERNET"), + ) +} + +// buildAcmeShopAPK is a typical release APK: v2-signed with a release key, +// resource label, icons at two densities plus an adaptive icon, permissions +// and two ABIs. +func buildAcmeShopAPK(t testing.TB) []byte { + return signWithV2(t, buildUnsignedAcmeShopAPK(t), acmeReleaseSubject) +} + +func buildUnsignedAcmeShopAPK(t testing.TB) []byte { + return buildAPKWithManifest(t, acmeShopManifest(), + zipEntry{name: androidResourcesPath, data: encodeResourceTable(acmeShopResources())}, + zipEntry{name: "res/mipmap-mdpi/ic_launcher.png", data: solidPNG(t, 48, 48, acmeGreen)}, + zipEntry{name: "res/mipmap-xxxhdpi/ic_launcher.webp", data: solidWebP(192, 192, acmeRed)}, + zipEntry{name: "res/mipmap-anydpi-v26/ic_launcher.xml", data: adaptiveIconXML()}, + zipEntry{name: "res/drawable/ic_launcher_foreground.png", data: solidPNG(t, 108, 108, acmeBlue)}, + zipEntry{name: "lib/arm64-v8a/libacme.so", data: []byte("elf")}, + zipEntry{name: "lib/armeabi-v7a/libacme.so", data: []byte("elf")}, + ) +} + +// buildAdaptiveIconAPK has only an adaptive icon, as apps with minSdk 26 +// often do, and an old-style v1 signature with the debug key. +func buildAdaptiveIconAPK(t testing.TB) []byte { + resources := []resEntry{ + {typeName: "mipmap", entry: 0, values: []resTestValue{ + fileAtDensity(densityAny, "res/mipmap-anydpi-v26/ic_launcher.xml"), + }}, + {typeName: "drawable", entry: 0, values: []resTestValue{ + fileAtDensity(0, "res/drawable/ic_launcher_foreground.png"), + }}, + } + manifest := manifestElement("com.acme.adaptive", attrs( + androidString("label", attrLabel, "Adaptive"), + androidReference("icon", attrIcon, resID("mipmap", 0)), + )) + return buildAPKWithManifest(t, manifest, + zipEntry{name: androidResourcesPath, data: encodeResourceTable(resources)}, + zipEntry{name: "res/mipmap-anydpi-v26/ic_launcher.xml", data: adaptiveIconXML()}, + zipEntry{name: "res/drawable/ic_launcher_foreground.png", data: solidPNG(t, 108, 108, acmeBlue)}, + zipEntry{name: "META-INF/CERT.RSA", data: pkcs7WithCertificate(t, selfSignedCertificate(t, androidDebugSubject))}, + ) +} + +func buildAPKWithIconFile(t testing.TB, path string, data []byte) []byte { + resources := []resEntry{{typeName: "mipmap", entry: 0, values: []resTestValue{fileAtDensity(0, path)}}} + manifest := manifestElement("com.acme.icon", attrs(androidReference("icon", attrIcon, resID("mipmap", 0)))) + return buildAPKWithManifest(t, manifest, + zipEntry{name: androidResourcesPath, data: encodeResourceTable(resources)}, + zipEntry{name: path, data: data}, + ) +} diff --git a/fixtures_apksign_test.go b/fixtures_apksign_test.go new file mode 100644 index 0000000..27d76f1 --- /dev/null +++ b/fixtures_apksign_test.go @@ -0,0 +1,128 @@ +package appmeta + +import ( + "bytes" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rand" + "crypto/x509" + "crypto/x509/pkix" + "encoding/asn1" + "encoding/binary" + "math/big" + "testing" + "time" +) + +type pkcs7TestContent struct { + Type asn1.ObjectIdentifier +} + +type pkcs7TestSignedData struct { + Version int + DigestAlgorithms []asn1.RawValue `asn1:"set"` + Content pkcs7TestContent + Certificates asn1.RawValue + SignerInfos []asn1.RawValue `asn1:"set"` +} + +type pkcs7TestContentInfo struct { + Type asn1.ObjectIdentifier + Content pkcs7TestSignedData `asn1:"explicit,tag:0"` +} + +type signingPair struct { + id uint32 + value []byte +} + +var ( + androidDebugSubject = pkix.Name{CommonName: "Android Debug", Organization: []string{"Android"}, Country: []string{"US"}} + acmeReleaseSubject = pkix.Name{CommonName: "Acme Shop", Organization: []string{"Acme"}, Country: []string{"US"}} +) + +// selfSignedCertificate returns the DER of a throwaway certificate. +func selfSignedCertificate(t testing.TB, subject pkix.Name) []byte { + t.Helper() + key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) + if err != nil { + t.Fatal(err) + } + template := &x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: subject, + NotBefore: time.Date(2020, 1, 1, 0, 0, 0, 0, time.UTC), + NotAfter: time.Date(2050, 1, 1, 0, 0, 0, 0, time.UTC), + } + der, err := x509.CreateCertificate(rand.Reader, template, template, &key.PublicKey, key) + if err != nil { + t.Fatal(err) + } + return der +} + +func withLengthPrefix(parts ...[]byte) []byte { + body := bytes.Join(parts, nil) + return append(u32s(uint32(len(body))), body...) +} + +// v2SignerValue lays out signers -> signer -> signed data with one +// certificate; the signature and public key are empty since appmeta does not +// verify them. +func v2SignerValue(cert []byte) []byte { + signedData := bytes.Join([][]byte{ + withLengthPrefix(), // digests + withLengthPrefix(withLengthPrefix(cert)), // certificates + withLengthPrefix(), // additional attributes + }, nil) + signer := bytes.Join([][]byte{withLengthPrefix(signedData), withLengthPrefix(), withLengthPrefix()}, nil) + return withLengthPrefix(withLengthPrefix(signer)) +} + +func signingBlock(pairs ...signingPair) []byte { + var body bytes.Buffer + for _, p := range pairs { + _ = binary.Write(&body, binary.LittleEndian, uint64(signingBlockPairIDLen+len(p.value))) + body.Write(u32s(p.id)) + body.Write(p.value) + } + size := uint64(body.Len() + signingBlockFooterLen) + var block bytes.Buffer + _ = binary.Write(&block, binary.LittleEndian, size) + block.Write(body.Bytes()) + _ = binary.Write(&block, binary.LittleEndian, size) + block.Write(signingBlockMagic) + return block.Bytes() +} + +// withSigningBlock inserts the block before the central directory and moves +// the directory offset, as apksigner does. +func withSigningBlock(t testing.TB, zipData, block []byte) []byte { + t.Helper() + eocd := bytes.LastIndex(zipData, eocdSignature) + cd := binary.LittleEndian.Uint32(zipData[eocd+16:]) + out := bytes.Join([][]byte{zipData[:cd], block, zipData[cd:]}, nil) + binary.LittleEndian.PutUint32(out[eocd+len(block)+16:], cd+uint32(len(block))) + return out +} + +func pkcs7WithCertificate(t testing.TB, cert []byte) []byte { + t.Helper() + der, err := asn1.Marshal(pkcs7TestContentInfo{ + Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}, + Content: pkcs7TestSignedData{ + Version: 1, + Content: pkcs7TestContent{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}}, + Certificates: asn1.RawValue{Class: asn1.ClassContextSpecific, Tag: 0, IsCompound: true, Bytes: cert}, + }, + }) + if err != nil { + t.Fatal(err) + } + return der +} + +func signWithV2(t testing.TB, apk []byte, subject pkix.Name) []byte { + block := signingBlock(signingPair{id: signingBlockV2ID, value: v2SignerValue(selfSignedCertificate(t, subject))}) + return withSigningBlock(t, apk, block) +} diff --git a/fixtures_arsc_test.go b/fixtures_arsc_test.go new file mode 100644 index 0000000..eb26eb5 --- /dev/null +++ b/fixtures_arsc_test.go @@ -0,0 +1,199 @@ +package appmeta + +import ( + "bytes" + "slices" +) + +// Test-side encoder for resources.arsc with one package (0x7f) and fixed +// type ids, so manifests can reference resources by resID(type, entry). + +var fixtureResourceTypes = []string{"string", "mipmap", "drawable"} + +const fixturePackageID = 0x7f + +type resEntry struct { + typeName string + entry uint16 + values []resTestValue +} + +type resTestValue struct { + language string + density uint16 + valueType uint8 + str string + data uint32 +} + +type resTableLayout int + +const ( + denseOffsets resTableLayout = iota + sparseOffsets + offset16WithCompactEntries +) + +func resID(typeName string, entry uint16) uint32 { + typeID := uint32(slices.Index(fixtureResourceTypes, typeName) + 1) + return fixturePackageID<<24 | typeID<<16 | uint32(entry) +} + +func stringValue(language, s string) resTestValue { + return resTestValue{language: language, valueType: resValueTypeString, str: s} +} + +func fileAtDensity(density uint16, path string) resTestValue { + return resTestValue{density: density, valueType: resValueTypeString, str: path} +} + +func referenceValue(ref uint32) resTestValue { + return resTestValue{valueType: resValueTypeReference, data: ref} +} + +func encodeResourceTable(entries []resEntry) []byte { + return encodeResourceTableLayout(entries, denseOffsets) +} + +func encodeResourceTableLayout(entries []resEntry, layout resTableLayout) []byte { + var values stringTable + var keys stringTable + for _, e := range entries { + keys.add(e.typeName + "_" + string(rune('a'+e.entry))) + for _, v := range e.values { + if v.valueType == resValueTypeString { + values.add(v.str) + } + } + } + + var pkgBody bytes.Buffer + typePool := encodeStringPool(fixtureResourceTypes, false) + keyPool := encodeStringPool(keys.strings, false) + pkgBody.Write(typePool) + pkgBody.Write(keyPool) + for i, typeName := range fixtureResourceTypes { + typeID := uint8(i + 1) + entryCount := uint32(0) + for _, e := range entries { + if e.typeName == typeName { + entryCount = max(entryCount, uint32(e.entry)+1) + } + } + if entryCount == 0 { + continue + } + specHeader := append([]byte{typeID, 0}, u16s(0)...) + specHeader = append(specHeader, u32s(entryCount)...) + pkgBody.Write(encodeChunk(0x0202, specHeader, make([]byte, 4*entryCount))) + for _, cfg := range configsOf(entries, typeName) { + pkgBody.Write(encodeTypeChunk(typeID, entryCount, cfg, entries, typeName, &values, &keys, layout)) + } + } + + pkgHeader := u32s(fixturePackageID) + pkgHeader = append(pkgHeader, make([]byte, 256)...) + headerSize := uint32(chunkHeaderLen + len(pkgHeader) + 5*4) + pkgHeader = append(pkgHeader, u32s(headerSize, 0, headerSize+uint32(len(typePool)), 0, 0)...) + + var body bytes.Buffer + body.Write(encodeStringPool(values.strings, false)) + body.Write(encodeChunk(chunkTablePackage, pkgHeader, pkgBody.Bytes())) + return encodeChunk(chunkTable, u32s(1), body.Bytes()) +} + +type resTestConfig struct { + language string + density uint16 +} + +func configsOf(entries []resEntry, typeName string) []resTestConfig { + var configs []resTestConfig + for _, e := range entries { + for _, v := range e.values { + c := resTestConfig{v.language, v.density} + if e.typeName == typeName && !slices.Contains(configs, c) { + configs = append(configs, c) + } + } + } + return configs +} + +func encodeTypeChunk(typeID uint8, entryCount uint32, cfg resTestConfig, entries []resEntry, typeName string, values, keys *stringTable, layout resTableLayout) []byte { + config := make([]byte, 64) + copy(config, u32s(64)) + copy(config[configLanguageOffset:], cfg.language) + copy(config[configDensityOffset:], u16s(cfg.density)) + + offsets := map[uint16]uint32{} + var entryData bytes.Buffer + for _, e := range entries { + if e.typeName != typeName { + continue + } + for _, v := range e.values { + if (resTestConfig{v.language, v.density}) != cfg { + continue + } + data := v.data + if v.valueType == resValueTypeString { + data = values.add(v.str) + } + key := keys.add(e.typeName + "_" + string(rune('a'+e.entry))) + offsets[e.entry] = uint32(entryData.Len()) + if layout == offset16WithCompactEntries { + entryData.Write(u16s(uint16(key), uint16(v.valueType)<<8|tableEntryFlagCompact)) + entryData.Write(u32s(data)) + continue + } + entryData.Write(u16s(tableEntryHeaderLen, 0)) + entryData.Write(u32s(key)) + entryData.Write(u16s(resValueLen)) + entryData.Write([]byte{0, v.valueType}) + entryData.Write(u32s(data)) + } + } + + var flags uint8 + var offsetTable []byte + count := entryCount + switch layout { + case sparseOffsets: + flags = tableTypeFlagSparse + count = 0 + for i := range uint16(entryCount) { + if off, ok := offsets[i]; ok { + offsetTable = append(offsetTable, u16s(i, uint16(off/4))...) + count++ + } + } + case offset16WithCompactEntries: + flags = tableTypeFlagOffset16 + for i := range uint16(entryCount) { + off, ok := offsets[i] + if !ok { + off = noEntry16 * 4 + } + offsetTable = append(offsetTable, u16s(uint16(off/4))...) + } + for len(offsetTable)%4 != 0 { + offsetTable = append(offsetTable, 0) + } + default: + for i := range uint16(entryCount) { + off, ok := offsets[i] + if !ok { + off = noEntry32 + } + offsetTable = append(offsetTable, u32s(off)...) + } + } + + headerSize := uint32(tableTypeHeaderLen + len(config)) + header := []byte{typeID, flags} + header = append(header, u16s(0)...) + header = append(header, u32s(count, headerSize+uint32(len(offsetTable)))...) + header = append(header, config...) + return encodeChunk(chunkTableType, header, append(offsetTable, entryData.Bytes()...)) +} diff --git a/fixtures_axml_test.go b/fixtures_axml_test.go new file mode 100644 index 0000000..4689568 --- /dev/null +++ b/fixtures_axml_test.go @@ -0,0 +1,216 @@ +package appmeta + +import ( + "bytes" + "encoding/binary" + "unicode/utf16" +) + +// Test-side encoder for Android binary XML and string pools, so fixture +// apps are built from source instead of shipping third-party binaries. + +type xmlNode struct { + name string + attrs []xmlTestAttr + children []xmlNode +} + +type xmlTestAttr struct { + name string + resID uint32 + android bool + valueType uint8 + str string + data uint32 +} + +const androidNamespace = "http://schemas.android.com/apk/res/android" + +func plainString(name, value string) xmlTestAttr { + return xmlTestAttr{name: name, valueType: resValueTypeString, str: value} +} + +func androidString(name string, resID uint32, value string) xmlTestAttr { + return xmlTestAttr{name: name, resID: resID, android: true, valueType: resValueTypeString, str: value} +} + +func androidInt(name string, resID uint32, value uint32) xmlTestAttr { + return xmlTestAttr{name: name, resID: resID, android: true, valueType: resValueTypeIntDec, data: value} +} + +func androidBool(name string, resID uint32, value bool) xmlTestAttr { + var data uint32 + if value { + data = 0xFFFFFFFF + } + return xmlTestAttr{name: name, resID: resID, android: true, valueType: resValueTypeBool, data: data} +} + +func androidReference(name string, resID uint32, ref uint32) xmlTestAttr { + return xmlTestAttr{name: name, resID: resID, android: true, valueType: resValueTypeReference, data: ref} +} + +func element(name string, attrs []xmlTestAttr, children ...xmlNode) xmlNode { + return xmlNode{name: name, attrs: attrs, children: children} +} + +func attrs(a ...xmlTestAttr) []xmlTestAttr { + return a +} + +// stringTable assigns pool indexes in insertion order. +type stringTable struct { + strings []string + index map[string]uint32 +} + +func (s *stringTable) add(v string) uint32 { + if s.index == nil { + s.index = map[string]uint32{} + } + if i, ok := s.index[v]; ok { + return i + } + s.index[v] = uint32(len(s.strings)) + s.strings = append(s.strings, v) + return s.index[v] +} + +func encodeAXML(root xmlNode) []byte { + return encodeAXMLStrings(root, false) +} + +func encodeAXMLStrings(root xmlNode, utf8 bool) []byte { + // Attribute names with resource ids come first, matching the resource map. + var table stringTable + var resourceIDs []uint32 + var collectIDs func(n xmlNode) + collectIDs = func(n xmlNode) { + for _, a := range n.attrs { + if a.resID == 0 { + continue + } + if _, seen := table.index[a.name]; !seen { + table.add(a.name) + resourceIDs = append(resourceIDs, a.resID) + } + } + for _, c := range n.children { + collectIDs(c) + } + } + collectIDs(root) + nsPrefix := table.add("android") + nsURI := table.add(androidNamespace) + + var nodes bytes.Buffer + nodes.Write(encodeChunk(0x0100, nodeHeader(), u32s(nsPrefix, nsURI))) + var writeNode func(n xmlNode) + writeNode = func(n xmlNode) { + name := table.add(n.name) + var ext bytes.Buffer + ext.Write(u32s(noStringIndex, name)) + ext.Write(u16s(xmlStartElementExtLen, xmlAttributeLen, uint16(len(n.attrs)), 0, 0, 0)) + for _, a := range n.attrs { + ns := uint32(noStringIndex) + if a.android { + ns = nsURI + } + raw := uint32(noStringIndex) + data := a.data + if a.valueType == resValueTypeString { + raw = table.add(a.str) + data = raw + } + ext.Write(u32s(ns, table.add(a.name), raw)) + ext.Write(u16s(8)) + ext.Write([]byte{0, a.valueType}) + ext.Write(u32s(data)) + } + nodes.Write(encodeChunk(chunkXMLStartElement, nodeHeader(), ext.Bytes())) + for _, c := range n.children { + writeNode(c) + } + nodes.Write(encodeChunk(chunkXMLEndElement, nodeHeader(), u32s(noStringIndex, name))) + } + writeNode(root) + nodes.Write(encodeChunk(0x0101, nodeHeader(), u32s(nsPrefix, nsURI))) + + var body bytes.Buffer + body.Write(encodeStringPool(table.strings, utf8)) + body.Write(encodeChunk(chunkXMLResourceMap, nil, u32s(resourceIDs...))) + body.Write(nodes.Bytes()) + return encodeChunk(chunkXML, nil, body.Bytes()) +} + +func nodeHeader() []byte { + return u32s(1, noStringIndex) +} + +// encodeChunk frames body with a ResChunk_header plus extraHeader. +func encodeChunk(typ uint16, extraHeader, body []byte) []byte { + headerSize := chunkHeaderLen + len(extraHeader) + var b bytes.Buffer + b.Write(u16s(typ, uint16(headerSize))) + b.Write(u32s(uint32(headerSize + len(body)))) + b.Write(extraHeader) + b.Write(body) + return b.Bytes() +} + +func encodeStringPool(strs []string, utf8 bool) []byte { + var data bytes.Buffer + var offsets []uint32 + for _, s := range strs { + offsets = append(offsets, uint32(data.Len())) + if utf8 { + data.Write(utf8PoolLength(len(utf16.Encode([]rune(s))))) + data.Write(utf8PoolLength(len(s))) + data.WriteString(s) + data.WriteByte(0) + continue + } + units := utf16.Encode([]rune(s)) + if len(units) > 0x7FFF { + data.Write(u16s(uint16(len(units)>>16)|0x8000, uint16(len(units)))) + } else { + data.Write(u16s(uint16(len(units)))) + } + data.Write(u16s(units...)) + data.Write(u16s(0)) + } + for data.Len()%4 != 0 { + data.WriteByte(0) + } + var flags uint32 + if utf8 { + flags = stringPoolUTF8Flag + } + stringsStart := uint32(stringPoolHeaderLen + 4*len(strs)) + header := u32s(uint32(len(strs)), 0, flags, stringsStart, 0) + body := append(u32s(offsets...), data.Bytes()...) + return encodeChunk(chunkStringPool, header, body) +} + +func utf8PoolLength(n int) []byte { + if n > 0x7F { + return []byte{byte(n>>8) | 0x80, byte(n)} + } + return []byte{byte(n)} +} + +func u32s(v ...uint32) []byte { + b := make([]byte, 4*len(v)) + for i, x := range v { + binary.LittleEndian.PutUint32(b[i*4:], x) + } + return b +} + +func u16s(v ...uint16) []byte { + b := make([]byte, 2*len(v)) + for i, x := range v { + binary.LittleEndian.PutUint16(b[i*2:], x) + } + return b +} diff --git a/fixtures_image_test.go b/fixtures_image_test.go new file mode 100644 index 0000000..5601e33 --- /dev/null +++ b/fixtures_image_test.go @@ -0,0 +1,106 @@ +package appmeta + +import ( + "bytes" + "image" + "image/color" + "image/png" + "testing" +) + +var ( + acmeRed = color.NRGBA{R: 220, G: 30, B: 40, A: 255} + acmeGreen = color.NRGBA{R: 20, G: 200, B: 60, A: 255} + acmeBlue = color.NRGBA{R: 10, G: 60, B: 230, A: 255} +) + +func solidImage(w, h int, c color.NRGBA) *image.NRGBA { + img := image.NewNRGBA(image.Rect(0, 0, w, h)) + for y := range h { + for x := range w { + img.SetNRGBA(x, y, c) + } + } + return img +} + +func solidPNG(t testing.TB, w, h int, c color.NRGBA) []byte { + t.Helper() + var buf bytes.Buffer + if err := png.Encode(&buf, solidImage(w, h, c)); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// solidWebP writes a lossless WebP whose five prefix codes each have a +// single symbol, so every pixel is the same colour and takes zero bits. +func solidWebP(w, h int, c color.NRGBA) []byte { + var bits webpBitWriter + bits.write(uint32(w-1), 14) + bits.write(uint32(h-1), 14) + bits.write(1, 1) // alpha is used + bits.write(0, 3) // version + bits.write(0, 1) // no transform + bits.write(0, 1) // no colour cache + bits.write(0, 1) // no meta prefix codes + for _, symbol := range []uint8{c.G, c.R, c.B, c.A, 0} { + bits.write(1, 1) // simple code + bits.write(0, 1) // one symbol + bits.write(1, 1) // 8-bit symbol + bits.write(uint32(symbol), 8) + } + payload := append([]byte{0x2f}, bits.bytes()...) + if len(payload)%2 == 1 { + payload = append(payload, 0) + } + + var out bytes.Buffer + out.WriteString("RIFF") + out.Write(u32s(uint32(4 + 8 + len(payload)))) + out.WriteString("WEBPVP8L") + out.Write(u32s(uint32(len(payload)))) + out.Write(payload) + return out.Bytes() +} + +// webpBitWriter packs bits least-significant first, as VP8L reads them. +type webpBitWriter struct { + buf []byte + acc uint64 + count uint +} + +func (b *webpBitWriter) write(v uint32, n uint) { + b.acc |= uint64(v) << b.count + b.count += n + for b.count >= 8 { + b.buf = append(b.buf, byte(b.acc)) + b.acc >>= 8 + b.count -= 8 + } +} + +func (b *webpBitWriter) bytes() []byte { + if b.count > 0 { + return append(b.buf, byte(b.acc)) + } + return b.buf +} + +// iconCenterColor decodes the extracted PNG and samples its middle pixel. +func iconCenterColor(t testing.TB, icon *Icon) color.NRGBA { + t.Helper() + if icon == nil { + t.Fatal("no icon extracted") + } + img, err := png.Decode(bytes.NewReader(icon.PNG)) + if err != nil { + t.Fatalf("icon is not a valid PNG: %v", err) + } + b := img.Bounds() + if b.Dx() != icon.Width || b.Dy() != icon.Height { + t.Fatalf("icon PNG is %dx%d but reported %dx%d", b.Dx(), b.Dy(), icon.Width, icon.Height) + } + return color.NRGBAModel.Convert(img.At(b.Dx()/2, b.Dy()/2)).(color.NRGBA) +} diff --git a/fixtures_ipa_test.go b/fixtures_ipa_test.go new file mode 100644 index 0000000..7d9d30e --- /dev/null +++ b/fixtures_ipa_test.go @@ -0,0 +1,172 @@ +package appmeta + +import ( + "bytes" + "compress/flate" + "compress/zlib" + "encoding/binary" + "hash/crc32" + "image" + "image/png" + "io" + "maps" + "testing" + + "howett.net/plist" +) + +const acmeBundle = "Payload/AcmeShop.app/" + +func acmeInfoPlist(overrides map[string]any) map[string]any { + dict := map[string]any{ + "CFBundleIdentifier": "com.acme.shop", + "CFBundleDisplayName": "Acme Shop", + "CFBundleName": "AcmeShop", + "CFBundleShortVersionString": "4.2.0", + "CFBundleVersion": "4201", + "CFBundleExecutable": "AcmeShop", + "MinimumOSVersion": "15.0", + "DTPlatformName": "iphoneos", + "DTPlatformVersion": "17.2", + "UIDeviceFamily": []int{1, 2}, + "CFBundleIcons": map[string]any{ + "CFBundlePrimaryIcon": map[string]any{ + "CFBundleIconFiles": []string{"AppIcon60x60"}, + "CFBundleIconName": "AppIcon", + }, + }, + } + maps.Copy(dict, overrides) + for k, v := range overrides { + if v == nil { + delete(dict, k) + } + } + return dict +} + +func binaryPlist(t testing.TB, v any) []byte { + t.Helper() + data, err := plist.Marshal(v, plist.BinaryFormat) + if err != nil { + t.Fatal(err) + } + return data +} + +func xmlPlist(t testing.TB, v any) []byte { + t.Helper() + data, err := plist.MarshalIndent(v, plist.XMLFormat, "\t") + if err != nil { + t.Fatal(err) + } + return data +} + +func buildIPA(t testing.TB, infoPlist []byte, extra ...zipEntry) []byte { + t.Helper() + entries := []zipEntry{{name: acmeBundle + "Info.plist", data: infoPlist}} + for _, e := range extra { + entries = append(entries, zipEntry{name: acmeBundle + e.name, data: e.data}) + } + return buildZip(t, entries...) +} + +// cgbiPNG encodes img the way Xcode does for device builds: premultiplied +// BGRA pixels, raw deflate, and a leading CgBI chunk. Go's PNG encoder does +// the row filtering; filters work per byte within a pixel, so swapping +// channels before filtering is equivalent to swapping after. +func cgbiPNG(t testing.TB, img *image.NRGBA) []byte { + t.Helper() + crushed := image.NewNRGBA(img.Bounds()) + for i := 0; i < len(img.Pix); i += 4 { + r, g, b, a := img.Pix[i], img.Pix[i+1], img.Pix[i+2], img.Pix[i+3] + premultiply := func(c uint8) uint8 { return uint8((uint32(c)*uint32(a) + 127) / 255) } + copy(crushed.Pix[i:], []byte{premultiply(b), premultiply(g), premultiply(r), a}) + } + // Go writes RGB for opaque images; CgBI is always RGBA. + crushed.Pix[3] = 0 + crushed.Pix[0], crushed.Pix[1], crushed.Pix[2] = 0, 0, 0 + + var standard bytes.Buffer + if err := png.Encode(&standard, crushed); err != nil { + t.Fatal(err) + } + ihdr, idat := pngChunks(t, standard.Bytes()) + zr, err := zlib.NewReader(bytes.NewReader(idat)) + if err != nil { + t.Fatal(err) + } + filtered, err := io.ReadAll(zr) + if err != nil { + t.Fatal(err) + } + var raw bytes.Buffer + fw, _ := flate.NewWriter(&raw, flate.BestCompression) + _, _ = fw.Write(filtered) + _ = fw.Close() + + var out bytes.Buffer + out.Write(pngSignature) + writePNGChunk(&out, "CgBI", []byte{0x50, 0x00, 0x20, 0x06}) + writePNGChunk(&out, "IHDR", ihdr) + writePNGChunk(&out, "IDAT", raw.Bytes()) + writePNGChunk(&out, "IEND", nil) + return out.Bytes() +} + +func pngChunks(t testing.TB, data []byte) (ihdr, idat []byte) { + t.Helper() + rest := data[len(pngSignature):] + for len(rest) >= 12 { + n := binary.BigEndian.Uint32(rest) + typ, body := string(rest[4:8]), rest[8:8+n] + switch typ { + case "IHDR": + ihdr = body + case "IDAT": + idat = append(idat, body...) + } + rest = rest[12+n:] + } + return ihdr, idat +} + +func writePNGChunk(w *bytes.Buffer, typ string, body []byte) { + _ = binary.Write(w, binary.BigEndian, uint32(len(body))) + w.WriteString(typ) + w.Write(body) + crc := crc32.NewIEEE() + crc.Write([]byte(typ)) + crc.Write(body) + _ = binary.Write(w, binary.BigEndian, crc.Sum32()) +} + +func acmeDeviceExecutable() zipEntry { + return zipEntry{name: "AcmeShop", data: fatMachO(thinMachO(cpuARM64, 0, platformIOS))} +} + +// buildAcmeShopIPA is a typical development build: arm64 executable, +// development profile and CgBI icons at two scales. +func buildAcmeShopIPA(t testing.TB) []byte { + return buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), + acmeDeviceExecutable(), + zipEntry{name: "embedded.mobileprovision", data: developmentProfile(t)}, + zipEntry{name: "AppIcon60x60@2x.png", data: cgbiPNG(t, solidImage(120, 120, acmeGreen))}, + zipEntry{name: "AppIcon60x60@3x.png", data: cgbiPNG(t, solidImage(180, 180, acmeRed))}, + zipEntry{name: "Assets.car", data: []byte("car")}, + ) +} + +// buildSimulatorIPA is a zipped simulator build: XML plist, universal +// simulator executable, no profile, icon only in Assets.car. +func buildSimulatorIPA(t testing.TB) []byte { + plist := acmeInfoPlist(map[string]any{"DTPlatformName": "iphonesimulator", "UIDeviceFamily": []int{1}}) + return buildIPA(t, xmlPlist(t, plist), + zipEntry{name: "AcmeShop", data: fatMachO( + thinMachO(cpuX86_64, 3, platformIOSSimulator), + thinMachO(cpuARM64, 0, platformIOSSimulator), + )}, + zipEntry{name: "Assets.car", data: []byte("car")}, + ) +} diff --git a/fixtures_macho_test.go b/fixtures_macho_test.go new file mode 100644 index 0000000..62f7cc9 --- /dev/null +++ b/fixtures_macho_test.go @@ -0,0 +1,115 @@ +package appmeta + +import ( + "bytes" + "encoding/asn1" + "encoding/binary" + "testing" + "time" +) + +const ( + platformIOS = 2 + platformIOSSimulator = 7 + cpuARM64 = cpuTypeARM | cpuArchABI64 + cpuX86_64 = cpuTypeX86 | cpuArchABI64 + fatSliceAlignment = 0x4000 +) + +type machOSlice struct { + cpuType, cpuSubtype uint32 + data []byte +} + +// thinMachO is a 64-bit executable header with one LC_BUILD_VERSION. +func thinMachO(cpuType, cpuSubtype, platform uint32) machOSlice { + var b bytes.Buffer + le32 := func(v ...uint32) { + for _, x := range v { + _ = binary.Write(&b, binary.LittleEndian, x) + } + } + const buildVersionLen = 24 + le32(machOMagic64, cpuType, cpuSubtype, 2, 1, buildVersionLen, 0, 0) + le32(lcBuildVersion, buildVersionLen, platform, 15<<16, 17<<16|2<<8, 0) + return machOSlice{cpuType: cpuType, cpuSubtype: cpuSubtype, data: b.Bytes()} +} + +// fatMachO places each slice at a 16 KiB boundary, as lipo does for arm64. +func fatMachO(slices ...machOSlice) []byte { + var b bytes.Buffer + be32 := func(v ...uint32) { + for _, x := range v { + _ = binary.Write(&b, binary.BigEndian, x) + } + } + be32(fatMagic, uint32(len(slices))) + offset := uint32(fatSliceAlignment) + for _, s := range slices { + be32(s.cpuType, s.cpuSubtype, offset, uint32(len(s.data)), 14) + offset += fatSliceAlignment + } + for i, s := range slices { + b.Write(make([]byte, fatSliceAlignment*(i+1)-b.Len())) + b.Write(s.data) + } + return b.Bytes() +} + +type profileOptions struct { + getTaskAllow bool + provisionedDevices []string + provisionsAllDevices bool +} + +// provisioningProfile wraps a profile plist in CMS SignedData with no +// signers, which is the layout of embedded.mobileprovision minus the +// signature appmeta does not verify. +func provisioningProfile(t testing.TB, opts profileOptions) []byte { + t.Helper() + profile := map[string]any{ + "Name": "Acme Shop Profile", + "TeamIdentifier": []string{"ACME123456"}, + "ExpirationDate": time.Date(2027, 3, 1, 12, 0, 0, 0, time.UTC), + "Entitlements": map[string]any{ + "get-task-allow": opts.getTaskAllow, + "com.apple.developer.team-identifier": "ACME123456", + }, + } + if opts.provisionedDevices != nil { + profile["ProvisionedDevices"] = opts.provisionedDevices + } + if opts.provisionsAllDevices { + profile["ProvisionsAllDevices"] = true + } + + type encapsulatedContent struct { + Type asn1.ObjectIdentifier + Content []byte `asn1:"explicit,tag:0"` + } + type signedData struct { + Version int + DigestAlgorithms []asn1.RawValue `asn1:"set"` + Content encapsulatedContent + SignerInfos []asn1.RawValue `asn1:"set"` + } + type contentInfo struct { + Type asn1.ObjectIdentifier + Content signedData `asn1:"explicit,tag:0"` + } + der, err := asn1.Marshal(contentInfo{ + Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 2}, + Content: signedData{ + Version: 1, + Content: encapsulatedContent{Type: asn1.ObjectIdentifier{1, 2, 840, 113549, 1, 7, 1}, Content: xmlPlist(t, profile)}, + }, + }) + if err != nil { + t.Fatal(err) + } + return der +} + +func developmentProfile(t testing.TB) []byte { + return provisioningProfile(t, profileOptions{getTaskAllow: true, provisionedDevices: []string{"00008110-000A1B2C3D4E5F60"}}) +} diff --git a/fixtures_zip_test.go b/fixtures_zip_test.go new file mode 100644 index 0000000..34dcba6 --- /dev/null +++ b/fixtures_zip_test.go @@ -0,0 +1,45 @@ +package appmeta + +import ( + "archive/zip" + "bytes" + "testing" +) + +type zipEntry struct { + name string + data []byte +} + +// buildZip writes the entries in order, deflated. +func buildZip(t testing.TB, entries ...zipEntry) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for _, e := range entries { + f, err := w.Create(e.name) + if err != nil { + t.Fatal(err) + } + if _, err := f.Write(e.data); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func parseBytes(data []byte, opts ...Option) (*Info, error) { + return Parse(bytes.NewReader(data), int64(len(data)), opts...) +} + +func mustParse(t testing.TB, data []byte, opts ...Option) *Info { + t.Helper() + info, err := parseBytes(data, opts...) + if err != nil { + t.Fatalf("Parse: %v", err) + } + return info +} diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..54a8a5e --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,92 @@ +package appmeta + +import ( + "bytes" + "testing" +) + +// Fuzz targets call the parsers directly, not through Parse, so a panic +// fails the fuzzer instead of being recovered into an error. + +func FuzzAXML(f *testing.F) { + f.Add(encodeAXML(acmeShopManifest())) + f.Add(encodeAXMLStrings(acmeShopManifest(), true)) + f.Add(adaptiveIconXML()) + f.Fuzz(func(t *testing.T, data []byte) { + _ = parseAXML(data, DefaultLimits().MaxDepth, func(path []string, attrs []xmlAttr) { + for _, a := range attrs { + _ = a.text() + } + }) + _, _ = parseManifest(data, DefaultLimits().MaxDepth) + }) +} + +func FuzzResourceTable(f *testing.F) { + for _, layout := range []resTableLayout{denseOffsets, sparseOffsets, offset16WithCompactEntries} { + f.Add(encodeResourceTableLayout(acmeShopResources(), layout), resID("string", 0)) + } + f.Fuzz(func(t *testing.T, data []byte, id uint32) { + table, err := parseResourceTable(data) + if err != nil { + return + } + for _, ref := range []uint32{id, resID("string", 0), resID("mipmap", 0), resID("drawable", 0)} { + _, _ = table.resolveText(ref) + _ = table.resolveFiles(ref) + } + }) +} + +func FuzzPlist(f *testing.F) { + f.Add(binaryPlist(f, acmeInfoPlist(nil))) + f.Add(xmlPlist(f, acmeInfoPlist(nil))) + f.Add(developmentProfile(f)) + f.Fuzz(func(t *testing.T, data []byte) { + if dict, err := decodePlist(data, DefaultLimits().MaxDepth); err == nil { + _ = deviceFamilies(dict) + _ = iconBaseNames(dict) + } + _, _, _ = parseProvisioningProfile(data, DefaultLimits().MaxDepth) + }) +} + +// FuzzIcon covers CgBI and the image decoders appmeta feeds untrusted data. +func FuzzIcon(f *testing.F) { + f.Add(cgbiPNG(f, solidImage(8, 8, acmeRed))) + f.Add(solidPNG(f, 8, 8, acmeGreen)) + f.Add(solidWebP(8, 8, acmeBlue)) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = encodeIcon(data, 256*256) + }) +} + +func FuzzMachO(f *testing.F) { + f.Add(fatMachO(thinMachO(cpuX86_64, 3, platformIOSSimulator), thinMachO(cpuARM64, 0, platformIOSSimulator))) + f.Add(thinMachO(cpuARM64, 2, platformIOS).data) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = sniffMachO(data) + }) +} + +func FuzzParse(f *testing.F) { + f.Add(buildMinimalAPK(f)) + f.Add(buildAcmeShopAPK(f)) + f.Add(buildAdaptiveIconAPK(f)) + f.Add(buildAcmeShopIPA(f)) + f.Add(buildSimulatorIPA(f)) + limits := Limits{MaxEntries: 1000, MaxEntrySize: 1 << 20, MaxTotalSize: 4 << 20, MaxDepth: 32, MaxIconPixels: 256 * 256} + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = parse(bytes.NewReader(data), int64(len(data)), limits) + }) +} + +func FuzzAPKSigning(f *testing.F) { + block := signingBlock(signingPair{id: signingBlockV2ID, value: v2SignerValue(selfSignedCertificate(f, androidDebugSubject))}) + f.Add(block[signingBlockLenLen : len(block)-signingBlockFooterLen]) + f.Add(pkcs7WithCertificate(f, selfSignedCertificate(f, acmeReleaseSubject))) + f.Fuzz(func(t *testing.T, data []byte) { + _, _ = certificateFromSigningPairs(data) + _, _ = pkcs7FirstCertificate(data) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..b4aa780 --- /dev/null +++ b/go.mod @@ -0,0 +1,18 @@ +module github.com/mobile-next/appmeta + +go 1.26.0 + +toolchain go1.26.6 + +require ( + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/spf13/cobra v1.10.2 + github.com/spf13/pflag v1.0.9 + golang.org/x/image v0.46.0 + howett.net/plist v1.0.1 +) + +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..de8b36d --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g= +github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxKI= +github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= +github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8= +github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= +github.com/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57HuXjmddZPjrls/28= +github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= +github.com/spf13/cobra v1.10.2 h1:DMTTonx5m65Ic0GOoRY2c16WCbHxOOw6xxezuLaBpcU= +github.com/spf13/cobra v1.10.2/go.mod h1:7C1pvHqHw5A4vrJfjNwvOdzYu0Gml16OCs2GRiTUUS4= +github.com/spf13/pflag v1.0.9 h1:9exaQaMOCwffKiiiYk6/BndUBv+iRViNW+4lEMi0PvY= +github.com/spf13/pflag v1.0.9/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= +golang.org/x/image v0.46.0/go.mod h1:3B3W05VGVQyuXucLINLjXKrqISASfi4Xj+iCVkLMwew= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v1 v1.0.0-20140924161607-9f9df34309c0/go.mod h1:WDnlLJ4WF5VGsH/HVa3CI79GS0ol3YnhVnKP89i0kNg= +howett.net/plist v1.0.1 h1:37GdZ8tP09Q35o9ych3ehygcsL+HqKSwzctveSlarvM= +howett.net/plist v1.0.1/go.mod h1:lqaXoTrLY4hg8tnEzNru53gicrbv7rrk+2xJA/7hw9g= diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..4978cb0 --- /dev/null +++ b/golden_test.go @@ -0,0 +1,115 @@ +package appmeta + +import ( + "bytes" + "encoding/json" + "flag" + "os" + "path/filepath" + "testing" + + "github.com/santhosh-tekuri/jsonschema/v6" +) + +var update = flag.Bool("update", false, "rewrite golden files in testdata/golden") + +type goldenFixture struct { + name string + build func(t testing.TB) []byte +} + +// goldenFixtures lists every synthetic app whose full output is pinned in +// testdata/golden/.json. +func goldenFixtures() []goldenFixture { + return []goldenFixture{ + {"apk-minimal", buildMinimalAPK}, + {"apk-acme-shop", buildAcmeShopAPK}, + {"apk-adaptive-icon", buildAdaptiveIconAPK}, + {"ipa-acme-shop", buildAcmeShopIPA}, + {"ipa-simulator", buildSimulatorIPA}, + } +} + +func TestEveryFixtureMatchesItsGoldenFileAndTheSchema(t *testing.T) { + schema := compileOutputSchema(t) + for _, fixture := range goldenFixtures() { + t.Run(fixture.name, func(t *testing.T) { + info := mustParse(t, fixture.build(t)) + assertMatchesSchema(t, schema, info) + assertMatchesGoldenFile(t, fixture.name, info) + }) + } +} + +func compileOutputSchema(t *testing.T) *jsonschema.Schema { + t.Helper() + raw, err := os.ReadFile("schema/appmeta.schema.json") + if err != nil { + t.Fatal(err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(raw)) + if err != nil { + t.Fatal(err) + } + c := jsonschema.NewCompiler() + if err := c.AddResource("appmeta.schema.json", doc); err != nil { + t.Fatal(err) + } + schema, err := c.Compile("appmeta.schema.json") + if err != nil { + t.Fatal(err) + } + return schema +} + +func assertMatchesSchema(t *testing.T, schema *jsonschema.Schema, info *Info) { + t.Helper() + encoded, err := json.Marshal(info) + if err != nil { + t.Fatal(err) + } + doc, err := jsonschema.UnmarshalJSON(bytes.NewReader(encoded)) + if err != nil { + t.Fatal(err) + } + if err := schema.Validate(doc); err != nil { + t.Errorf("output does not match schema: %v\n%s", err, encoded) + } +} + +// assertMatchesGoldenFile compares everything except the icon bytes, which +// depend on the Go version's PNG encoder; icon pixels are checked by +// dedicated tests. +func assertMatchesGoldenFile(t *testing.T, name string, info *Info) { + t.Helper() + pinned := *info + if pinned.Icon != nil { + icon := *pinned.Icon + icon.PNG = nil + icon.SHA256 = "" + pinned.Icon = &icon + } + got, err := json.MarshalIndent(pinned, "", " ") + if err != nil { + t.Fatal(err) + } + got = append(got, '\n') + + path := filepath.Join("testdata", "golden", name+".json") + if *update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, got, 0o644); err != nil { + t.Fatal(err) + } + return + } + want, err := os.ReadFile(path) + if err != nil { + t.Fatalf("%v (run with -update to create it)", err) + } + if !bytes.Equal(got, want) { + t.Errorf("output differs from %s (run with -update and review the diff)\ngot:\n%s", path, got) + } +} diff --git a/hardening_test.go b/hardening_test.go new file mode 100644 index 0000000..203dc49 --- /dev/null +++ b/hardening_test.go @@ -0,0 +1,213 @@ +package appmeta + +import ( + "archive/zip" + "bytes" + "context" + "encoding/binary" + "errors" + "fmt" + "io" + "strings" + "testing" + "time" +) + +var errInjectedReadFailure = errors.New("injected network failure") + +func TestAReadFailureIsAnErrorAndNeverAWarning(t *testing.T) { + data := buildAcmeShopAPK(t) + for healthyReads := 1; ; healthyReads++ { + reader := &failingAfterReads{r: bytes.NewReader(data), remaining: healthyReads} + info, err := Parse(reader, int64(len(data))) + if !reader.failed { + return // every read the parser needs succeeded + } + if !errors.Is(err, errInjectedReadFailure) { + t.Fatalf("after %d healthy reads: got %+v, %v; want the read failure", healthyReads, info, err) + } + } +} + +func TestACancelledParseNeverReturnsPartialMetadata(t *testing.T) { + data := buildAcmeShopAPK(t) + for healthyReads := 1; ; healthyReads++ { + ctx, cancel := context.WithCancel(context.Background()) + reader := cancelAfterReads{r: bytes.NewReader(data), remaining: healthyReads, cancel: cancel} + info, err := ParseContext(ctx, &reader, int64(len(data))) + wasCancelled := ctx.Err() != nil + cancel() + if !wasCancelled { + return + } + if !errors.Is(err, context.Canceled) { + t.Fatalf("cancelled at read %d: got %+v, %v; want context.Canceled", healthyReads, info, err) + } + } +} + +func TestMoreEntriesThanDeclaredAreStillLimited(t *testing.T) { + data := zipDeclaringEntries(t, zipWithEntries(t, 65536), 0) + _, err := parseBytes(data, WithLimits(Limits{MaxEntries: 100})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func TestADirectorySizeMarkerSendsTheReaderToTheZip64Record(t *testing.T) { + data := zipWithEntries(t, 65536) + eocd := bytes.LastIndex(data, eocdSignature) + binary.LittleEndian.PutUint16(data[eocd+10:], 5) + binary.LittleEndian.PutUint32(data[eocd+16:], 0) + + dir, err := readZipDirectory(bytes.NewReader(data), int64(len(data))) + if err != nil { + t.Fatal(err) + } + assertEqual(t, "entries", dir.entries, 65536) +} + +func TestAZip64LocatorInAFileTooSmallForItsRecordIsUnsupported(t *testing.T) { + locator := bytes.Join([][]byte{zip64LocatorSignature, make([]byte, zip64LocatorLen-4)}, nil) + eocd := bytes.Join([][]byte{eocdSignature, make([]byte, eocdLen-4)}, nil) + binary.LittleEndian.PutUint16(eocd[10:], zip16BitEntryCountMax) + _, err := parseBytes(append(locator, eocd...)) + if !errors.Is(err, ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) + } +} + +func TestTheSigningBlockCountsTowardsTheTotalLimit(t *testing.T) { + block := signingBlock( + signingPair{id: 0x42726577, value: make([]byte, 200_000)}, + signingPair{id: signingBlockV2ID, value: v2SignerValue(selfSignedCertificate(t, acmeReleaseSubject))}, + ) + info := mustParse(t, withSigningBlock(t, buildMinimalAPK(t), block), WithLimits(Limits{MaxTotalSize: 100_000})) + if info.Signing != nil { + t.Fatalf("want no signing, got %+v", info.Signing) + } + assertWarningMentions(t, info, "limit exceeded") +} + +func TestManyPermissionsAreParsedInLinearTime(t *testing.T) { + permissions := make([]xmlNode, 60_000) + for i := range permissions { + permissions[i] = usesPermission(fmt.Sprintf("com.acme.permission.P%d", i)) + } + manifest := encodeAXML(manifestElement("com.acme.perms", nil, permissions...)) + + start := time.Now() + m, err := parseManifest(manifest, DefaultLimits().MaxDepth) + if err != nil { + t.Fatal(err) + } + assertEqual(t, "permissions", len(m.permissions), len(permissions)) + if elapsed := time.Since(start); elapsed > 2*time.Second { + t.Fatalf("parsing took %v", elapsed) + } +} + +func TestBracketsInsideXMLPlistStringsAreNotNesting(t *testing.T) { + plist := acmeInfoPlist(map[string]any{"NSCameraUsageDescription": strings.Repeat(":( ", 100)}) + info := mustParse(t, buildIPA(t, xmlPlist(t, plist))) + assertEqual(t, "bundleId", info.BundleID, "com.acme.shop") +} + +func TestAMissingResourceTableIsNamedOnceInTheWarning(t *testing.T) { + manifest := manifestElement("com.acme.noresources", attrs(androidReference("label", attrLabel, 0x7f010000))) + info := mustParse(t, buildAPKWithManifest(t, manifest)) + assertWarningMentions(t, info, androidResourcesPath) + for _, w := range info.Warnings { + if strings.Contains(w, androidResourcesPath+": "+androidResourcesPath) { + t.Errorf("warning repeats the path: %q", w) + } + } +} + +func TestTheChosenIconIsReadFromTheArchiveOnce(t *testing.T) { + plist := binaryPlist(t, acmeInfoPlist(map[string]any{"CFBundleIcons": nil, "CFBundleIconFiles": []string{"Icon.png"}})) + icon := solidPNG(t, 300, 300, acmeGreen) + roomForOneRead := int64(len(plist) + len(icon) + 10) + info := mustParse(t, buildIPA(t, plist, zipEntry{name: "Icon.png", data: icon}), WithLimits(Limits{MaxTotalSize: roomForOneRead})) + if info.Icon == nil { + t.Fatalf("want an icon; warnings: %q", info.Warnings) + } +} + +type failingAfterReads struct { + r io.ReaderAt + remaining int + failed bool +} + +func (f *failingAfterReads) ReadAt(p []byte, off int64) (int, error) { + if f.remaining <= 0 { + f.failed = true + return 0, errInjectedReadFailure + } + f.remaining-- + return f.r.ReadAt(p, off) +} + +// zipWithEntries writes n empty stored entries, the first of which is the +// Android manifest. From 65536 entries on, the writer adds a zip64 record. +func zipWithEntries(t testing.TB, n int) []byte { + t.Helper() + var buf bytes.Buffer + w := zip.NewWriter(&buf) + for i := range n { + name := fmt.Sprintf("file%d", i) + if i == 0 { + name = androidManifestPath + } + if _, err := w.CreateHeader(&zip.FileHeader{Name: name, Method: zip.Store}); err != nil { + t.Fatal(err) + } + } + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +// zipDeclaringEntries rewrites the end of central directory record as a +// 32-bit one that declares the given number of entries, as a hostile archive +// would to slip past a check of the declared count. +func zipDeclaringEntries(t testing.TB, data []byte, declared uint16) []byte { + t.Helper() + directory := bytes.Index(data, []byte("PK\x01\x02")) + directoryEnd := bytes.LastIndex(data, zip64EOCDSignature) + eocd := bytes.LastIndex(data, eocdSignature) + binary.LittleEndian.PutUint16(data[eocd+8:], declared) + binary.LittleEndian.PutUint16(data[eocd+10:], declared) + binary.LittleEndian.PutUint32(data[eocd+12:], uint32(directoryEnd-directory)) + binary.LittleEndian.PutUint32(data[eocd+16:], uint32(directory)) + return data +} + +func TestMalformedAppsAreReportedAsMalformed(t *testing.T) { + manifest := encodeAXML(manifestElement("com.acme.truncated", nil)) + malformed := map[string][]byte{ + "truncated manifest": buildZip(t, zipEntry{name: androidManifestPath, data: manifest[:len(manifest)/2]}), + "truncated plist": buildIPA(t, []byte("bplist00")), + } + for name, data := range malformed { + t.Run(name, func(t *testing.T) { + _, err := parseBytes(data) + if !errors.Is(err, ErrMalformed) { + t.Fatalf("got %v, want ErrMalformed", err) + } + }) + } +} + +func TestFormatPlatformAndSigningUseTheExportedNames(t *testing.T) { + apk := mustParse(t, buildAcmeShopAPK(t)) + assertEqual(t, "format", apk.Format, FormatAPK) + assertEqual(t, "platform", apk.Platform, PlatformAndroid) + assertSigningType(t, apk, SigningRelease) + + ipa := mustParse(t, buildAcmeShopIPA(t)) + assertEqual(t, "format", ipa.Format, FormatIPA) + assertEqual(t, "platform", ipa.Platform, PlatformIOS) +} diff --git a/icon.go b/icon.go new file mode 100644 index 0000000..1f08b6c --- /dev/null +++ b/icon.go @@ -0,0 +1,119 @@ +package appmeta + +import ( + "bytes" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "image" + "image/jpeg" + "image/png" + + "golang.org/x/image/draw" + "golang.org/x/image/webp" +) + +var ( + pngSignature = []byte("\x89PNG\r\n\x1a\n") + jpegSignature = []byte{0xFF, 0xD8, 0xFF} + riffSignature = []byte("RIFF") + webpSignature = []byte("WEBP") +) + +var errUnknownImageFormat = errors.New("appmeta: unknown image format") + +type imageCodec struct { + decode func([]byte) (image.Image, error) + decodeConfig func([]byte) (image.Config, error) +} + +// codecFor sniffs the format. Decoders are called directly rather than +// registered with package image, so the host's image registry is untouched. +func codecFor(data []byte) (imageCodec, error) { + switch { + case bytes.HasPrefix(data, pngSignature) && isCgBI(data): + return imageCodec{decode: decodeCgBI, decodeConfig: decodeCgBIConfig}, nil + case bytes.HasPrefix(data, pngSignature): + return imageCodec{ + decode: func(b []byte) (image.Image, error) { return png.Decode(bytes.NewReader(b)) }, + decodeConfig: func(b []byte) (image.Config, error) { return png.DecodeConfig(bytes.NewReader(b)) }, + }, nil + case bytes.HasPrefix(data, jpegSignature): + return imageCodec{ + decode: func(b []byte) (image.Image, error) { return jpeg.Decode(bytes.NewReader(b)) }, + decodeConfig: func(b []byte) (image.Config, error) { return jpeg.DecodeConfig(bytes.NewReader(b)) }, + }, nil + case len(data) >= 12 && bytes.HasPrefix(data, riffSignature) && bytes.Equal(data[8:12], webpSignature): + return imageCodec{ + decode: func(b []byte) (image.Image, error) { return webp.Decode(bytes.NewReader(b)) }, + decodeConfig: func(b []byte) (image.Config, error) { return webp.DecodeConfig(bytes.NewReader(b)) }, + }, nil + } + return imageCodec{}, errUnknownImageFormat +} + +// imageSize reads only the image header. +func imageSize(data []byte) (int, int, error) { + codec, err := codecFor(data) + if err != nil { + return 0, 0, err + } + cfg, err := codec.decodeConfig(data) + if err != nil { + return 0, 0, err + } + return cfg.Width, cfg.Height, nil +} + +// encodeIcon decodes a PNG (standard or CgBI), JPEG or WebP and re-encodes +// it as a PNG no larger than MaxIconSize, so the host only ever serves +// images appmeta produced. +func encodeIcon(data []byte, maxPixels int) (*Icon, error) { + codec, err := codecFor(data) + if err != nil { + return nil, err + } + cfg, err := codec.decodeConfig(data) + if err != nil { + return nil, err + } + if cfg.Width <= 0 || cfg.Height <= 0 || cfg.Width > maxPixels/cfg.Height { + return nil, fmt.Errorf("%w: icon is %dx%d", ErrLimitExceeded, cfg.Width, cfg.Height) + } + img, err := codec.decode(data) + if err != nil { + return nil, err + } + img = fitWithin(img, MaxIconSize) + + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + return nil, err + } + sum := sha256.Sum256(buf.Bytes()) + return &Icon{ + ContentType: "image/png", + Width: img.Bounds().Dx(), + Height: img.Bounds().Dy(), + SHA256: hex.EncodeToString(sum[:]), + PNG: buf.Bytes(), + }, nil +} + +func fitWithin(img image.Image, side int) image.Image { + w, h := img.Bounds().Dx(), img.Bounds().Dy() + if w <= side && h <= side { + return img + } + if w >= h { + h = max(1, h*side/w) + w = side + } else { + w = max(1, w*side/h) + h = side + } + dst := image.NewNRGBA(image.Rect(0, 0, w, h)) + draw.CatmullRom.Scale(dst, dst.Bounds(), img, img.Bounds(), draw.Src, nil) + return dst +} diff --git a/ipa.go b/ipa.go new file mode 100644 index 0000000..dfb48a2 --- /dev/null +++ b/ipa.go @@ -0,0 +1,211 @@ +package appmeta + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// maxIconCandidates bounds how many icon names are matched and how many icon +// files are opened to compare sizes; real apps declare a handful. +const maxIconCandidates = 32 + +var iosDeviceFamilies = map[int64]string{1: "phone", 2: "tablet", 3: "tv", 4: "watch", 7: "vision"} + +// iconVariantSuffix matches what Xcode appends to CFBundleIconFiles names. +var iconVariantSuffix = regexp.MustCompile(`^(@[23]x)?(~ipad|~iphone)?\.png$`) + +// findAppBundle returns the "Payload/.app/" prefix of the main app. +func findAppBundle(a *archive) string { + best := "" + for name := range a.files { + rest, ok := strings.CutPrefix(name, "Payload/") + if !ok { + continue + } + app, file, ok := strings.Cut(rest, "/") + bundle := "Payload/" + app + "/" + // Pick the smallest name so the result does not depend on map order. + if ok && strings.HasSuffix(app, ".app") && file == "Info.plist" && (best == "" || bundle < best) { + best = bundle + } + } + return best +} + +func parseIPA(a *archive) (*Info, error) { + bundle := findAppBundle(a) + data, err := a.read(bundle + "Info.plist") + if err != nil { + return nil, err + } + plist, err := decodePlist(data, a.limits.MaxDepth) + if err != nil { + return nil, fmt.Errorf("%sInfo.plist: %w", bundle, err) + } + bundleID := plistString(plist, "CFBundleIdentifier") + if bundleID == "" { + return nil, fmt.Errorf("%sInfo.plist: %w: no CFBundleIdentifier", bundle, errMalformedPlist) + } + + info := &Info{ + Format: FormatIPA, + Platform: PlatformIOS, + BundleID: bundleID, + Name: firstNonEmpty(plistString(plist, "CFBundleDisplayName"), plistString(plist, "CFBundleName"), bundleID), + Version: plistString(plist, "CFBundleShortVersionString"), + BuildNumber: plistString(plist, "CFBundleVersion"), + MinOSVersion: plistString(plist, "MinimumOSVersion"), + TargetOSVersion: plistString(plist, "DTPlatformVersion"), + IsSimulator: strings.Contains(strings.ToLower(plistString(plist, "DTPlatformName")), "simulator"), + DeviceFamilies: deviceFamilies(plist), + } + executable, warning := sniffExecutable(a, bundle, plistString(plist, "CFBundleExecutable")) + info.Warnings = appendWarning(info.Warnings, warning) + info.Architectures = executable.architectures + // The Mach-O platform wins over DTPlatformName, which is only what Xcode + // wrote into Info.plist. + if executable.hasPlatform { + info.IsSimulator = executable.isSimulator + } + info.Signing, info.IsDebuggable, warning = readProvisioningProfile(a, bundle) + info.Warnings = appendWarning(info.Warnings, warning) + info.Icon, warning = extractIPAIcon(a, bundle, plist) + info.Warnings = appendWarning(info.Warnings, warning) + return info, nil +} + +func sniffExecutable(a *archive, bundle, executable string) (machOSummary, string) { + if executable == "" { + return machOSummary{}, "architectures unknown: Info.plist has no CFBundleExecutable" + } + data, err := a.readPrefix(bundle+executable, machOPrefixLen) + if err != nil { + return machOSummary{}, fmt.Sprintf("architectures unknown: %v", err) + } + summary, err := sniffMachO(data) + if err != nil { + return machOSummary{}, fmt.Sprintf("architectures unknown: %s: %v", executable, err) + } + return summary, "" +} + +// readProvisioningProfile returns no signing when there is no profile, as +// for simulator builds and App Store downloads. +func readProvisioningProfile(a *archive, bundle string) (signing *Signing, isDebuggable bool, warning string) { + path := bundle + "embedded.mobileprovision" + if !a.has(path) { + return nil, false, "" + } + data, err := a.read(path) + if err == nil { + signing, isDebuggable, err = parseProvisioningProfile(data, a.limits.MaxDepth) + } + if err != nil { + return nil, false, fmt.Sprintf("signing unknown: embedded.mobileprovision: %v", err) + } + return signing, isDebuggable, "" +} + +func firstNonEmpty(values ...string) string { + for _, v := range values { + if v != "" { + return v + } + } + return "" +} + +// deviceFamilies defaults to phone, as iOS does when UIDeviceFamily is absent. +func deviceFamilies(plist map[string]any) []string { + var families []string + for _, id := range plistInts(plist, "UIDeviceFamily") { + if name, ok := iosDeviceFamilies[id]; ok && !slices.Contains(families, name) { + families = append(families, name) + } + } + if len(families) == 0 { + return []string{"phone"} + } + return families +} + +func extractIPAIcon(a *archive, bundle string, plist map[string]any) (*Icon, string) { + path, data := largestIconFile(a, iconCandidates(a, bundle, iconBaseNames(plist))) + if path == "" { + primary := plistDict(plistDict(plist, "CFBundleIcons"), "CFBundlePrimaryIcon") + if a.has(bundle+"Assets.car") || plistString(primary, "CFBundleIconName") != "" { + return nil, "icon not extracted: it is only in Assets.car" + } + return nil, "icon not extracted: no icon file found" + } + icon, err := encodeIcon(data, a.limits.MaxIconPixels) + if err != nil { + return nil, fmt.Sprintf("icon not extracted: %s: %v", strings.TrimPrefix(path, bundle), err) + } + return icon, "" +} + +// largestIconFile returns the path and content of the candidate with the +// most pixels, or an empty path when none is a readable image. +func largestIconFile(a *archive, candidates []string) (string, []byte) { + var bestPath string + var bestData []byte + bestArea := 0 + for _, path := range candidates { + data, err := a.read(path) + if err != nil { + continue + } + w, h, err := imageSize(data) + if err == nil && w*h > bestArea { + bestPath, bestData, bestArea = path, data, w*h + } + } + return bestPath, bestData +} + +// iconBaseNames lists icon names from every place Info.plist declares them. +func iconBaseNames(plist map[string]any) []string { + var names []string + for _, key := range []string{"CFBundleIcons", "CFBundleIcons~ipad"} { + primary := plistDict(plistDict(plist, key), "CFBundlePrimaryIcon") + names = append(names, plistStrings(primary, "CFBundleIconFiles")...) + } + names = append(names, plistStrings(plist, "CFBundleIconFiles")...) + names = append(names, plistStrings(plist, "CFBundleIconFile")...) + + var unique []string + for _, n := range names { + n = strings.TrimSuffix(n, ".png") + if n != "" && !slices.Contains(unique, n) && len(unique) < maxIconCandidates { + unique = append(unique, n) + } + } + return unique +} + +// iconCandidates finds the files in the bundle root that are variants of the +// declared icon names, e.g. AppIcon60x60 -> AppIcon60x60@3x.png. +func iconCandidates(a *archive, bundle string, baseNames []string) []string { + var out []string + for name := range a.files { + file, ok := strings.CutPrefix(name, bundle) + if !ok || strings.Contains(file, "/") { + continue + } + for _, base := range baseNames { + suffix, ok := strings.CutPrefix(file, base) + if ok && iconVariantSuffix.MatchString(suffix) { + out = append(out, name) + break + } + } + } + slices.Sort(out) + if len(out) > maxIconCandidates { + out = out[:maxIconCandidates] + } + return out +} diff --git a/ipa_test.go b/ipa_test.go new file mode 100644 index 0000000..c53037a --- /dev/null +++ b/ipa_test.go @@ -0,0 +1,165 @@ +package appmeta + +import ( + "errors" + "image" + "image/color" + "testing" +) + +func TestAnIPAInfoPlistYieldsBundleVersionAndOS(t *testing.T) { + info := mustParse(t, buildAcmeShopIPA(t)) + assertEqual(t, "format", info.Format, "ipa") + assertEqual(t, "platform", info.Platform, "ios") + assertEqual(t, "bundleId", info.BundleID, "com.acme.shop") + assertEqual(t, "name", info.Name, "Acme Shop") + assertEqual(t, "version", info.Version, "4.2.0") + assertEqual(t, "buildNumber", info.BuildNumber, "4201") + assertEqual(t, "minOsVersion", info.MinOSVersion, "15.0") + assertEqual(t, "targetOsVersion", info.TargetOSVersion, "17.2") + assertSlice(t, "deviceFamilies", info.DeviceFamilies, []string{"phone", "tablet"}) +} + +func TestXMLInfoPlistsParseLikeBinaryOnes(t *testing.T) { + info := mustParse(t, buildIPA(t, xmlPlist(t, acmeInfoPlist(nil)))) + assertEqual(t, "bundleId", info.BundleID, "com.acme.shop") + assertEqual(t, "version", info.Version, "4.2.0") +} + +func TestTheNameFallsBackFromDisplayNameToBundleNameToBundleID(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"CFBundleDisplayName": nil})))) + assertEqual(t, "name", info.Name, "AcmeShop") + + info = mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"CFBundleDisplayName": nil, "CFBundleName": nil})))) + assertEqual(t, "name", info.Name, "com.acme.shop") +} + +func TestANumericBundleVersionIsReportedAsText(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"CFBundleVersion": 77})))) + assertEqual(t, "buildNumber", info.BuildNumber, "77") +} + +func TestDeviceFamiliesComeFromUIDeviceFamilyAndDefaultToPhone(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"UIDeviceFamily": []int{3}})))) + assertSlice(t, "deviceFamilies", info.DeviceFamilies, []string{"tv"}) + + info = mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"UIDeviceFamily": nil})))) + assertSlice(t, "deviceFamilies", info.DeviceFamilies, []string{"phone"}) +} + +func TestTheLargestDeclaredIconIsChosenAndItsCgBIEncodingIsUndone(t *testing.T) { + info := mustParse(t, buildAcmeShopIPA(t)) + assertEqual(t, "icon width", info.Icon.Width, 180) + assertEqual(t, "icon color", iconCenterColor(t, info.Icon), acmeRed) + if len(info.Warnings) != 0 { + t.Errorf("want no warnings, got %q", info.Warnings) + } +} + +func TestCgBIDecodingReproducesEveryPixelOfAGradient(t *testing.T) { + src := image.NewNRGBA(image.Rect(0, 0, 37, 23)) + for y := range 23 { + for x := range 37 { + src.SetNRGBA(x, y, color.NRGBA{R: uint8(x * 7), G: uint8(y * 11), B: uint8(x*y + 3), A: 255}) + } + } + decoded, err := decodeCgBI(cgbiPNG(t, src)) + if err != nil { + t.Fatal(err) + } + for y := range 23 { + for x := range 37 { + if x == 0 && y == 0 { + continue // made transparent by cgbiPNG + } + got := color.NRGBAModel.Convert(decoded.At(x, y)) + if got != src.NRGBAAt(x, y) { + t.Fatalf("pixel %d,%d: got %v, want %v", x, y, got, src.NRGBAAt(x, y)) + } + } + } +} + +func TestCgBIPremultipliedAlphaIsUndone(t *testing.T) { + translucent := color.NRGBA{R: 200, G: 100, B: 50, A: 128} + decoded, err := decodeCgBI(cgbiPNG(t, solidImage(4, 4, translucent))) + if err != nil { + t.Fatal(err) + } + got := color.NRGBAModel.Convert(decoded.At(2, 2)).(color.NRGBA) + if absDiff(got.R, translucent.R) > 1 || absDiff(got.G, translucent.G) > 1 || absDiff(got.B, translucent.B) > 1 || got.A != translucent.A { + t.Fatalf("got %v, want about %v", got, translucent) + } +} + +func TestAnIconOnlyInAssetsCarLeavesTheIconEmptyWithAWarning(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), zipEntry{name: "Assets.car", data: []byte("car")})) + if info.Icon != nil { + t.Fatal("want no icon") + } + assertWarningMentions(t, info, "Assets.car") +} + +func TestLegacyIconFilesAreFound(t *testing.T) { + plist := acmeInfoPlist(map[string]any{"CFBundleIcons": nil, "CFBundleIconFiles": []string{"Icon.png"}}) + info := mustParse(t, buildIPA(t, binaryPlist(t, plist), + zipEntry{name: "Icon.png", data: solidPNG(t, 57, 57, acmeGreen)}, + zipEntry{name: "Icon@2x.png", data: solidPNG(t, 114, 114, acmeBlue)}, + zipEntry{name: "IconUnrelated.png", data: solidPNG(t, 300, 300, acmeRed)}, + )) + assertEqual(t, "icon width", info.Icon.Width, 114) + assertEqual(t, "icon color", iconCenterColor(t, info.Icon), acmeBlue) +} + +func TestAnIPAWithoutABundleIdentifierIsAnError(t *testing.T) { + _, err := parseBytes(buildIPA(t, binaryPlist(t, acmeInfoPlist(map[string]any{"CFBundleIdentifier": nil})))) + if err == nil { + t.Fatal("want an error") + } +} + +func TestTheMainAppIsChosenOverNestedAppBundles(t *testing.T) { + watch := binaryPlist(t, map[string]any{"CFBundleIdentifier": "com.acme.shop.watch"}) + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), + zipEntry{name: "Watch/AcmeWatch.app/Info.plist", data: watch})) + assertEqual(t, "bundleId", info.BundleID, "com.acme.shop") +} + +func TestPlistsNestedDeeperThanTheLimitAreRejected(t *testing.T) { + var deep any = "leaf" + for range 10 { + deep = []any{deep} + } + plist := acmeInfoPlist(map[string]any{"Deep": deep}) + for name, encoded := range map[string][]byte{"binary": binaryPlist(t, plist), "xml": xmlPlist(t, plist)} { + t.Run(name, func(t *testing.T) { + _, err := parseBytes(buildIPA(t, encoded), WithLimits(Limits{MaxDepth: 6})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } + }) + } +} + +func TestATextPlistWithHostileNestingIsRejectedBeforeDecoding(t *testing.T) { + hostile := []byte(" = " + string(repeatByte('(', 100000)) + ";") + _, err := parseBytes(buildIPA(t, hostile)) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func repeatByte(b byte, n int) []byte { + out := make([]byte, n) + for i := range out { + out[i] = b + } + return out +} + +func absDiff(a, b uint8) uint8 { + if a > b { + return a - b + } + return b - a +} diff --git a/limits.go b/limits.go new file mode 100644 index 0000000..52b5e47 --- /dev/null +++ b/limits.go @@ -0,0 +1,61 @@ +package appmeta + +// Limits bound the work Parse does on one input. The defaults fit real-world +// apps with a wide margin; lower them for tighter memory or latency budgets. +type Limits struct { + // MaxEntries is the most zip entries the archive may declare. It is + // checked before the central directory is read. + MaxEntries int + // MaxEntrySize is the most uncompressed bytes read from one zip entry. + MaxEntrySize int64 + // MaxTotalSize is the most uncompressed bytes read across all entries. + MaxTotalSize int64 + // MaxDepth is the deepest element nesting accepted in binary XML and plists. + MaxDepth int + // MaxIconPixels is the largest width*height of a source icon that will + // be decoded. + MaxIconPixels int +} + +// MaxIconSize is the largest width and height of an extracted icon; larger +// icons are scaled down. +const MaxIconSize = 512 + +// DefaultLimits returns the limits Parse uses unless WithLimits overrides them. +func DefaultLimits() Limits { + return Limits{ + MaxEntries: 200_000, + MaxEntrySize: 64 << 20, + MaxTotalSize: 256 << 20, + MaxDepth: 64, + MaxIconPixels: 4096 * 4096, + } +} + +// Option configures Parse. +type Option func(*config) + +type config struct { + limits Limits +} + +// WithLimits overrides the default limits. Zero fields keep their default. +func WithLimits(l Limits) Option { + return func(c *config) { + if l.MaxEntries > 0 { + c.limits.MaxEntries = l.MaxEntries + } + if l.MaxEntrySize > 0 { + c.limits.MaxEntrySize = l.MaxEntrySize + } + if l.MaxTotalSize > 0 { + c.limits.MaxTotalSize = l.MaxTotalSize + } + if l.MaxDepth > 0 { + c.limits.MaxDepth = l.MaxDepth + } + if l.MaxIconPixels > 0 { + c.limits.MaxIconPixels = l.MaxIconPixels + } + } +} diff --git a/limits_test.go b/limits_test.go new file mode 100644 index 0000000..2bca4a9 --- /dev/null +++ b/limits_test.go @@ -0,0 +1,115 @@ +package appmeta + +import ( + "archive/zip" + "bytes" + "compress/flate" + "errors" + "hash/crc32" + "runtime" + "testing" +) + +func TestAnEntryLargerThanTheEntryLimitIsRejected(t *testing.T) { + data := buildMinimalAPK(t) + _, err := parseBytes(data, WithLimits(Limits{MaxEntrySize: 64})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func TestReadingMoreThanTheTotalLimitIsRejected(t *testing.T) { + data := buildAcmeShopAPK(t) + _, err := parseBytes(data, WithLimits(Limits{MaxTotalSize: 600})) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func TestAZipBombManifestIsRejectedWithoutInflatingIt(t *testing.T) { + zeros := make([]byte, 128<<20) + data := buildZip(t, zipEntry{name: androidManifestPath, data: zeros}) + if len(data) > 1<<20 { + t.Fatalf("fixture should be small, is %d bytes", len(data)) + } + _, err := parseBytes(data) + if !errors.Is(err, ErrLimitExceeded) { + t.Fatalf("got %v, want ErrLimitExceeded", err) + } +} + +func TestAnEntryThatInflatesBeyondItsDeclaredSizeIsCutOffAtTheLimit(t *testing.T) { + data := buildZipWithFalseSize(t, androidManifestPath, make([]byte, 1<<20), 100) + _, err := parseBytes(data, WithLimits(Limits{MaxEntrySize: 1000})) + if err == nil { + t.Fatal("want an error") + } +} + +func TestParseNeverLetsAPanicEscape(t *testing.T) { + info, err := Parse(panickingReader{}, 1<<20) + if info != nil || err == nil { + t.Fatalf("got %v, %v; want an error", info, err) + } +} + +func TestZeroLimitFieldsKeepTheirDefaults(t *testing.T) { + cfg := config{limits: DefaultLimits()} + WithLimits(Limits{MaxDepth: 3})(&cfg) + want := DefaultLimits() + want.MaxDepth = 3 + assertEqual(t, "limits", cfg.limits, want) +} + +type panickingReader struct{} + +func (panickingReader) ReadAt([]byte, int64) (int, error) { + panic("storage backend exploded") +} + +// buildZipWithFalseSize writes an entry whose header understates its +// uncompressed size, as a hostile archive would. +func buildZipWithFalseSize(t testing.TB, name string, content []byte, declared uint64) []byte { + t.Helper() + var compressed bytes.Buffer + fw, _ := flate.NewWriter(&compressed, flate.BestCompression) + _, _ = fw.Write(content) + _ = fw.Close() + + var buf bytes.Buffer + w := zip.NewWriter(&buf) + f, err := w.CreateRaw(&zip.FileHeader{ + Name: name, + Method: zip.Deflate, + CRC32: crc32.ChecksumIEEE(content), + CompressedSize64: uint64(compressed.Len()), + UncompressedSize64: declared, + }) + if err != nil { + t.Fatal(err) + } + _, _ = f.Write(compressed.Bytes()) + if err := w.Close(); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestAStringPoolDeclaringBillionsOfStringsAllocatesAlmostNothing(t *testing.T) { + // 36 bytes that made the surveyed androidbinary library allocate 1 GiB. + hostile := encodeChunk(chunkXML, nil, encodeChunk(chunkStringPool, u32s(0x08000000, 0, 0, 0, 0), nil)) + allocated := bytesAllocatedBy(func() { + _ = parseAXML(hostile, DefaultLimits().MaxDepth, func([]string, []xmlAttr) {}) + }) + if allocated > 1<<20 { + t.Fatalf("allocated %d bytes for a %d-byte input", allocated, len(hostile)) + } +} + +func bytesAllocatedBy(f func()) uint64 { + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + f() + runtime.ReadMemStats(&after) + return after.TotalAlloc - before.TotalAlloc +} diff --git a/macho.go b/macho.go new file mode 100644 index 0000000..ef53a0a --- /dev/null +++ b/macho.go @@ -0,0 +1,167 @@ +package appmeta + +import ( + "encoding/binary" + "fmt" + "slices" + "strconv" +) + +const ( + machOPrefixLen = 1 << 20 + + fatMagic = 0xCAFEBABE + fatMagic64 = 0xCAFEBABF + fatArchLen = 20 + fatArch64 = 32 + maxFatArch = 32 + + machOMagic32 = 0xFEEDFACE + machOMagic64 = 0xFEEDFACF + machOHeaderLen32 = 28 + machOHeaderLen64 = 32 + loadCommandLen = 8 + + lcVersionMinIPhoneOS = 0x25 + lcVersionMinTVOS = 0x2F + lcVersionMinWatchOS = 0x30 + lcBuildVersion = 0x32 + + cpuArchABI64 = 0x01000000 + cpuArchABI64_32 = 0x02000000 + cpuTypeX86 = 7 + cpuTypeARM = 12 + cpuSubtypeMask = 0x00FFFFFF +) + +// Simulator platforms from LC_BUILD_VERSION (mach-o/loader.h PLATFORM_*). +var simulatorPlatforms = []uint32{7, 8, 9, 12} + +type machOSummary struct { + architectures []string + isSimulator bool + // hasPlatform is false when no load command named the platform, in which + // case isSimulator is only a guess from the CPU. + hasPlatform bool +} + +// sniffMachO reads the architectures and target platform from the start of +// a thin or fat Mach-O. Only the first slice's load commands are read, so +// data can be a prefix of the executable. +func sniffMachO(data []byte) (machOSummary, error) { + if len(data) < 8 { + return machOSummary{}, fmt.Errorf("executable too short") + } + magic := binary.BigEndian.Uint32(data) + if magic != fatMagic && magic != fatMagic64 { + return sniffThinMachO(data) + } + + n := uint64(binary.BigEndian.Uint32(data[4:])) + entryLen := uint64(fatArchLen) + if magic == fatMagic64 { + entryLen = fatArch64 + } + if n == 0 || n > maxFatArch || 8+n*entryLen > uint64(len(data)) { + return machOSummary{}, fmt.Errorf("bad fat header with %d architectures", n) + } + var summary machOSummary + firstOffset := uint64(0) + for i := range n { + arch := data[8+i*entryLen:] + name := cpuName(binary.BigEndian.Uint32(arch), binary.BigEndian.Uint32(arch[4:])) + if !slices.Contains(summary.architectures, name) { + summary.architectures = append(summary.architectures, name) + } + offset := uint64(binary.BigEndian.Uint32(arch[8:])) + if magic == fatMagic64 { + offset = binary.BigEndian.Uint64(arch[8:]) + } + if i == 0 || offset < firstOffset { + firstOffset = offset + } + } + if firstOffset < uint64(len(data)) { + if slice, err := sniffThinMachO(data[firstOffset:]); err == nil { + summary.isSimulator, summary.hasPlatform = slice.isSimulator, slice.hasPlatform + } + } + return summary, nil +} + +func sniffThinMachO(data []byte) (machOSummary, error) { + if len(data) < machOHeaderLen32 { + return machOSummary{}, fmt.Errorf("executable too short") + } + headerLen := uint64(machOHeaderLen32) + switch binary.LittleEndian.Uint32(data) { + case machOMagic64: + headerLen = machOHeaderLen64 + case machOMagic32: + default: + return machOSummary{}, fmt.Errorf("not a mach-o executable") + } + cpuType := binary.LittleEndian.Uint32(data[4:]) + summary := machOSummary{ + architectures: []string{cpuName(cpuType, binary.LittleEndian.Uint32(data[8:]))}, + isSimulator: cpuType&^cpuArchABI64 == cpuTypeX86, + } + ncmds := uint64(binary.LittleEndian.Uint32(data[16:])) + off := headerLen + for range min(ncmds, uint64(len(data))/loadCommandLen) { + if off+loadCommandLen > uint64(len(data)) { + break + } + cmd := binary.LittleEndian.Uint32(data[off:]) + size := uint64(binary.LittleEndian.Uint32(data[off+4:])) + if size < loadCommandLen { + break + } + switch cmd { + case lcBuildVersion: + if off+12 <= uint64(len(data)) { + platform := binary.LittleEndian.Uint32(data[off+8:]) + summary.isSimulator = slices.Contains(simulatorPlatforms, platform) + summary.hasPlatform = true + return summary, nil + } + case lcVersionMinIPhoneOS, lcVersionMinTVOS, lcVersionMinWatchOS: + // Old toolchains used the same command for device and simulator, + // so the CPU decides. + summary.hasPlatform = true + } + off += size + } + return summary, nil +} + +func cpuName(cpuType, subtype uint32) string { + subtype &= cpuSubtypeMask + switch cpuType { + case cpuTypeARM | cpuArchABI64: + if subtype == 2 { + return "arm64e" + } + return "arm64" + case cpuTypeARM | cpuArchABI64_32: + return "arm64_32" + case cpuTypeARM: + switch subtype { + case 9: + return "armv7" + case 11: + return "armv7s" + case 12: + return "armv7k" + } + return "arm" + case cpuTypeX86 | cpuArchABI64: + if subtype == 8 { + return "x86_64h" + } + return "x86_64" + case cpuTypeX86: + return "i386" + } + return "cpu-" + strconv.FormatUint(uint64(cpuType), 10) +} diff --git a/macho_test.go b/macho_test.go new file mode 100644 index 0000000..3681038 --- /dev/null +++ b/macho_test.go @@ -0,0 +1,94 @@ +package appmeta + +import ( + "testing" + "time" +) + +func TestADeviceBuildReportsItsArchitecturesAndIsNotASimulator(t *testing.T) { + info := mustParse(t, buildAcmeShopIPA(t)) + assertSlice(t, "architectures", info.Architectures, []string{"arm64"}) + if info.IsSimulator { + t.Error("want a device build") + } +} + +func TestASimulatorBuildIsDetectedFromTheExecutable(t *testing.T) { + info := mustParse(t, buildSimulatorIPA(t)) + assertSlice(t, "architectures", info.Architectures, []string{"x86_64", "arm64"}) + if !info.IsSimulator { + t.Error("want a simulator build") + } +} + +func TestTheExecutableOverridesAMisleadingPlatformName(t *testing.T) { + plist := acmeInfoPlist(map[string]any{"DTPlatformName": "iphonesimulator"}) + info := mustParse(t, buildIPA(t, binaryPlist(t, plist), acmeDeviceExecutable())) + if info.IsSimulator { + t.Error("want a device build") + } +} + +func TestAThinExecutableIsRecognised(t *testing.T) { + exe := thinMachO(cpuARM64, 2, platformIOS).data + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), zipEntry{name: "AcmeShop", data: exe})) + assertSlice(t, "architectures", info.Architectures, []string{"arm64e"}) +} + +func TestAMissingExecutableIsAWarningNotAnError(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)))) + assertSlice(t, "architectures", info.Architectures, []string{}) + assertWarningMentions(t, info, "architectures unknown") +} + +func TestAnExecutableThatIsNotMachOIsAWarning(t *testing.T) { + info := mustParse(t, buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), zipEntry{name: "AcmeShop", data: []byte("#!/bin/sh\necho this is a shell script, not an app\n")})) + assertWarningMentions(t, info, "not a mach-o") +} + +func TestADevelopmentProfileReportsTeamExpiryAndDebuggable(t *testing.T) { + info := mustParse(t, buildAcmeShopIPA(t)) + if info.Signing == nil { + t.Fatal("want signing") + } + assertEqual(t, "type", info.Signing.Type, "development") + assertEqual(t, "teamId", info.Signing.TeamID, "ACME123456") + assertEqual(t, "expiresAt", *info.Signing.ExpiresAt, time.Date(2027, 3, 1, 12, 0, 0, 0, time.UTC)) + if !info.IsDebuggable { + t.Error("want debuggable (get-task-allow)") + } +} + +func TestProfileTypesAreTellApartByDevicesAndEntitlements(t *testing.T) { + cases := map[string]profileOptions{ + "development": {getTaskAllow: true, provisionedDevices: []string{"device"}}, + "ad-hoc": {provisionedDevices: []string{"device"}}, + "enterprise": {provisionsAllDevices: true}, + "app-store": {}, + } + for want, opts := range cases { + t.Run(want, func(t *testing.T) { + ipa := buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), acmeDeviceExecutable(), + zipEntry{name: "embedded.mobileprovision", data: provisioningProfile(t, opts)}) + info := mustParse(t, ipa) + assertEqual(t, "type", info.Signing.Type, want) + }) + } +} + +func TestAnIPAWithoutAProfileHasNoSigning(t *testing.T) { + info := mustParse(t, buildSimulatorIPA(t)) + if info.Signing != nil { + t.Fatalf("want no signing, got %+v", info.Signing) + } +} + +func TestACorruptProfileIsAWarning(t *testing.T) { + ipa := buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), acmeDeviceExecutable(), + zipEntry{name: "embedded.mobileprovision", data: []byte("\x30\x82garbage")}) + info := mustParse(t, ipa) + if info.Signing != nil { + t.Fatal("want no signing") + } + assertWarningMentions(t, info, "signing unknown") +} diff --git a/mobileprovision.go b/mobileprovision.go new file mode 100644 index 0000000..197cba3 --- /dev/null +++ b/mobileprovision.go @@ -0,0 +1,60 @@ +package appmeta + +import ( + "bytes" + "fmt" + "time" +) + +var ( + xmlDeclaration = []byte("") +) + +// parseProvisioningProfile reads embedded.mobileprovision, a CMS SignedData +// blob whose content is an XML plist. The signature is not verified (the +// device does that at install time), so the plist is located directly +// instead of decoding the CMS structure. +func parseProvisioningProfile(data []byte, maxDepth int) (*Signing, bool, error) { + start := bytes.Index(data, xmlDeclaration) + if start < 0 { + return nil, false, fmt.Errorf("%w: no plist in provisioning profile", errMalformedPlist) + } + end := bytes.Index(data[start:], plistEndTag) + if end < 0 { + return nil, false, fmt.Errorf("%w: unterminated plist in provisioning profile", errMalformedPlist) + } + profile, err := decodePlist(data[start:start+end+len(plistEndTag)], maxDepth) + if err != nil { + return nil, false, err + } + + entitlements := plistDict(profile, "Entitlements") + getTaskAllow := plistBool(entitlements, "get-task-allow") + signing := &Signing{Type: profileType(profile, getTaskAllow)} + if teams := plistStrings(profile, "TeamIdentifier"); len(teams) > 0 { + signing.TeamID = teams[0] + } else { + signing.TeamID = plistString(entitlements, "com.apple.developer.team-identifier") + } + if expires, ok := profile["ExpirationDate"].(time.Time); ok { + utc := expires.UTC() + signing.ExpiresAt = &utc + } + return signing, getTaskAllow, nil +} + +// profileType follows how Xcode distinguishes profiles: enterprise profiles +// provision all devices, development and ad-hoc ones list devices, and +// App Store ones list none. +func profileType(profile map[string]any, getTaskAllow bool) string { + switch { + case plistBool(profile, "ProvisionsAllDevices"): + return SigningEnterprise + case len(plistStrings(profile, "ProvisionedDevices")) > 0 && getTaskAllow: + return SigningDevelopment + case len(plistStrings(profile, "ProvisionedDevices")) > 0: + return SigningAdHoc + } + return SigningAppStore +} diff --git a/plist.go b/plist.go new file mode 100644 index 0000000..49afa9f --- /dev/null +++ b/plist.go @@ -0,0 +1,301 @@ +package appmeta + +import ( + "bytes" + "encoding/binary" + "encoding/xml" + "errors" + "fmt" + "io" + "strconv" + + "howett.net/plist" +) + +const ( + bplistTrailerLen = 32 + bplistHeaderLen = 8 + bplistMarkerArray = 0xA + bplistMarkerSet = 0xC + bplistMarkerDict = 0xD + bplistMarkerInt = 0x1 + bplistCountInFollowingInt = 0xF +) + +var ( + bplistMagic = []byte("bplist") + errMalformedPlist = fmt.Errorf("%w: plist", ErrMalformed) +) + +// decodePlist decodes a property list into a dictionary. howett.net/plist +// recurses per nesting level, so depth is checked first, iteratively, to keep +// hostile nesting off the goroutine stack. Input that is not binary goes to +// its XML parser and, unless the document is an XML plist, on to its text +// parser, so both nestings are checked. +func decodePlist(data []byte, maxDepth int) (map[string]any, error) { + var err error + if bytes.HasPrefix(data, bplistMagic) { + err = checkBinaryPlistDepth(data, maxDepth) + } else { + err = checkXMLDepth(data, maxDepth) + if err == nil && !isXMLPlist(data) { + err = checkBracketDepth(data, maxDepth) + } + } + if err != nil { + return nil, err + } + var dict map[string]any + if _, err := plist.Unmarshal(data, &dict); err != nil { + return nil, fmt.Errorf("%w: %v", errMalformedPlist, err) + } + return dict, nil +} + +// isXMLPlist reports whether the first XML element is . From there on +// howett.net/plist reports failures as XML errors; it only hands the input to +// its text parser when the XML fails before or at the first element. +func isXMLPlist(data []byte) bool { + d := xml.NewDecoder(bytes.NewReader(data)) + for { + tok, err := d.Token() + if err != nil { + return false + } + if element, ok := tok.(xml.StartElement); ok { + return element.Name.Local == "plist" + } + } +} + +func checkXMLDepth(data []byte, maxDepth int) error { + d := xml.NewDecoder(bytes.NewReader(data)) + depth := 0 + for { + tok, err := d.RawToken() + if errors.Is(err, io.EOF) { + return nil + } + if err != nil { + return fmt.Errorf("%w: %v", errMalformedPlist, err) + } + switch tok.(type) { + case xml.StartElement: + depth++ + if depth > maxDepth { + return fmt.Errorf("%w: plist nested deeper than %d", ErrLimitExceeded, maxDepth) + } + case xml.EndElement: + depth-- + } + } +} + +// checkBracketDepth bounds the nesting the text plist parser could reach. It +// ignores quoting, so it can only overestimate: brackets inside strings count, +// and a closing bracket never takes the depth below zero. +func checkBracketDepth(data []byte, maxDepth int) error { + depth := 0 + for _, b := range data { + switch b { + case '(', '{': + depth++ + if depth > maxDepth { + return fmt.Errorf("%w: plist nested deeper than %d", ErrLimitExceeded, maxDepth) + } + case ')', '}': + depth = max(0, depth-1) + } + } + return nil +} + +// bplist is the part of a binary plist needed to walk its object graph. +type bplist struct { + data []byte + offsetIntSize uint64 + objectRefSize uint64 + numObjects uint64 + offsetTable uint64 +} + +type bplistFrame struct { + refsStart uint64 + count uint64 + next uint64 +} + +// checkBinaryPlistDepth walks the object graph depth-first, entering each +// object once, which is the order and caching howett.net/plist uses, so the +// deepest stack found here is the deepest recursion the decoder will reach. +func checkBinaryPlistDepth(data []byte, maxDepth int) error { + p, top, err := parseBplistTrailer(data) + if err != nil { + return err + } + visited := make([]bool, p.numObjects) + var stack []bplistFrame + enter := func(obj uint64) error { + if obj >= p.numObjects || visited[obj] { + return nil + } + visited[obj] = true + frame, isContainer, err := p.container(obj) + if err != nil || !isContainer { + return err + } + if len(stack) >= maxDepth { + return fmt.Errorf("%w: plist nested deeper than %d", ErrLimitExceeded, maxDepth) + } + stack = append(stack, frame) + return nil + } + if err := enter(top); err != nil { + return err + } + for len(stack) > 0 { + f := &stack[len(stack)-1] + if f.next == f.count { + stack = stack[:len(stack)-1] + continue + } + ref := p.uint(f.refsStart+f.next*p.objectRefSize, p.objectRefSize) + f.next++ + if err := enter(ref); err != nil { + return err + } + } + return nil +} + +func parseBplistTrailer(data []byte) (*bplist, uint64, error) { + if len(data) < bplistHeaderLen+bplistTrailerLen { + return nil, 0, fmt.Errorf("%w: too short", errMalformedPlist) + } + t := data[len(data)-bplistTrailerLen:] + p := &bplist{ + data: data, + offsetIntSize: uint64(t[6]), + objectRefSize: uint64(t[7]), + numObjects: binary.BigEndian.Uint64(t[8:]), + offsetTable: binary.BigEndian.Uint64(t[24:]), + } + top := binary.BigEndian.Uint64(t[16:]) + body := uint64(len(data) - bplistTrailerLen) + if p.offsetIntSize < 1 || p.offsetIntSize > 8 || p.objectRefSize < 1 || p.objectRefSize > 8 || + p.offsetTable > body || p.numObjects > (body-p.offsetTable)/p.offsetIntSize { + return nil, 0, fmt.Errorf("%w: bad trailer", errMalformedPlist) + } + return p, top, nil +} + +// uint reads a big-endian integer of 1 to 8 bytes; callers bound off+size. +func (p *bplist) uint(off, size uint64) uint64 { + var v uint64 + for _, b := range p.data[off : off+size] { + v = v<<8 | uint64(b) + } + return v +} + +// container returns the reference list of an array, set or dict object. +func (p *bplist) container(obj uint64) (bplistFrame, bool, error) { + off := p.uint(p.offsetTable+obj*p.offsetIntSize, p.offsetIntSize) + if off >= p.offsetTable { + return bplistFrame{}, false, fmt.Errorf("%w: object %d out of bounds", errMalformedPlist, obj) + } + marker := p.data[off] + kind := marker >> 4 + if kind != bplistMarkerArray && kind != bplistMarkerSet && kind != bplistMarkerDict { + return bplistFrame{}, false, nil + } + count := uint64(marker & 0x0F) + refsStart := off + 1 + if count == bplistCountInFollowingInt { + if refsStart >= p.offsetTable || p.data[refsStart]>>4 != bplistMarkerInt { + return bplistFrame{}, false, fmt.Errorf("%w: bad container count", errMalformedPlist) + } + size := uint64(1) << (p.data[refsStart] & 0x0F) + if size > 8 || refsStart+1+size > p.offsetTable { + return bplistFrame{}, false, fmt.Errorf("%w: bad container count", errMalformedPlist) + } + count = p.uint(refsStart+1, size) + refsStart += 1 + size + } + if kind == bplistMarkerDict { + if count > p.offsetTable { + return bplistFrame{}, false, fmt.Errorf("%w: container overflows", errMalformedPlist) + } + count *= 2 + } + if count > (p.offsetTable-refsStart)/p.objectRefSize { + return bplistFrame{}, false, fmt.Errorf("%w: container overflows", errMalformedPlist) + } + return bplistFrame{refsStart: refsStart, count: count}, true, nil +} + +// Plist values are loosely typed in practice (numbers where strings are +// expected and the reverse), so accessors coerce instead of failing. + +func plistString(dict map[string]any, key string) string { + switch v := dict[key].(type) { + case string: + return v + case uint64: + return strconv.FormatUint(v, 10) + case int64: + return strconv.FormatInt(v, 10) + case float64: + return strconv.FormatFloat(v, 'f', -1, 64) + } + return "" +} + +func plistDict(dict map[string]any, key string) map[string]any { + v, _ := dict[key].(map[string]any) + return v +} + +func plistBool(dict map[string]any, key string) bool { + v, _ := dict[key].(bool) + return v +} + +// plistStrings accepts an array of strings or a single string. +func plistStrings(dict map[string]any, key string) []string { + switch v := dict[key].(type) { + case string: + return []string{v} + case []any: + var out []string + for _, item := range v { + if s, ok := item.(string); ok { + out = append(out, s) + } + } + return out + } + return nil +} + +// plistInts accepts an array or a single value of integers or numeric strings. +func plistInts(dict map[string]any, key string) []int64 { + items, ok := dict[key].([]any) + if !ok { + items = []any{dict[key]} + } + var out []int64 + for _, item := range items { + switch v := item.(type) { + case uint64: + out = append(out, int64(v)) + case int64: + out = append(out, v) + case string: + if n, err := strconv.ParseInt(v, 10, 64); err == nil { + out = append(out, n) + } + } + } + return out +} diff --git a/resource_chunk.go b/resource_chunk.go new file mode 100644 index 0000000..9c0470a --- /dev/null +++ b/resource_chunk.go @@ -0,0 +1,183 @@ +package appmeta + +import ( + "encoding/binary" + "fmt" + "unicode/utf16" +) + +// Chunk types shared by Android binary XML and resources.arsc +// (frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h). +const ( + chunkStringPool = 0x0001 + chunkTable = 0x0002 + chunkXML = 0x0003 + chunkXMLStartElement = 0x0102 + chunkXMLEndElement = 0x0103 + chunkXMLResourceMap = 0x0180 + chunkTablePackage = 0x0200 + chunkTableType = 0x0201 + chunkHeaderLen = 8 + stringPoolHeaderLen = 28 + stringPoolUTF8Flag = 1 << 8 + stringPoolHighBit16 = 0x8000 + stringPoolHighBit8 = 0x80 + resValueTypeReference = 0x01 + resValueTypeString = 0x03 + resValueTypeDynamicRef = 0x07 + resValueTypeIntDec = 0x10 + resValueTypeIntHex = 0x11 + resValueTypeBool = 0x12 +) + +var errMalformedResource = fmt.Errorf("%w: android resource", ErrMalformed) + +var le = binary.LittleEndian + +// chunk is one ResChunk_header-framed block. data covers the whole chunk, +// header included, and never extends past the enclosing buffer. +type chunk struct { + typ uint16 + headerSize int + data []byte +} + +// nextChunk splits the first chunk off b. +func nextChunk(b []byte) (chunk, []byte, error) { + if len(b) < chunkHeaderLen { + return chunk{}, nil, fmt.Errorf("%w: truncated chunk header", errMalformedResource) + } + typ := le.Uint16(b) + headerSize := int(le.Uint16(b[2:])) + size := uint64(le.Uint32(b[4:])) + if headerSize < chunkHeaderLen || uint64(headerSize) > size || size > uint64(len(b)) { + return chunk{}, nil, fmt.Errorf("%w: chunk 0x%04x has header %d and size %d in %d bytes", errMalformedResource, typ, headerSize, size, len(b)) + } + return chunk{typ: typ, headerSize: headerSize, data: b[:size]}, b[size:], nil +} + +// body is the part of the chunk after its header. +func (c chunk) body() []byte { + return c.data[c.headerSize:] +} + +// stringPool decodes strings lazily, so a pool that declares many strings +// costs nothing until they are used. Many indexes may point at the same or +// overlapping bytes, so the total decoded is capped at a multiple of the pool +// size rather than trusting the index count. +type stringPool struct { + data []byte + offsets []byte + count uint32 + utf8 bool + stringsStart uint64 + cache map[uint32]string + decoded uint64 +} + +// stringPoolDecodeFactor bounds decoded bytes relative to the pool size; real +// pools decode each string about once. +const stringPoolDecodeFactor = 4 + +func parseStringPool(c chunk) (*stringPool, error) { + if c.typ != chunkStringPool || c.headerSize < stringPoolHeaderLen { + return nil, fmt.Errorf("%w: bad string pool header", errMalformedResource) + } + count := le.Uint32(c.data[8:]) + flags := le.Uint32(c.data[16:]) + stringsStart := uint64(le.Uint32(c.data[20:])) + offsetsEnd := uint64(c.headerSize) + uint64(count)*4 + if offsetsEnd > uint64(len(c.data)) || stringsStart > uint64(len(c.data)) { + return nil, fmt.Errorf("%w: string pool declares %d strings in %d bytes", errMalformedResource, count, len(c.data)) + } + return &stringPool{ + data: c.data, + offsets: c.data[c.headerSize:offsetsEnd], + count: count, + utf8: flags&stringPoolUTF8Flag != 0, + stringsStart: stringsStart, + cache: map[uint32]string{}, + }, nil +} + +func (p *stringPool) get(i uint32) (string, error) { + if p == nil || i >= p.count { + return "", fmt.Errorf("%w: string %d out of range", errMalformedResource, i) + } + if s, ok := p.cache[i]; ok { + return s, nil + } + off := p.stringsStart + uint64(le.Uint32(p.offsets[i*4:])) + if off >= uint64(len(p.data)) { + return "", fmt.Errorf("%w: string %d starts past the pool", errMalformedResource, i) + } + var s string + var err error + if p.utf8 { + s, err = decodeUTF8PoolString(p.data[off:]) + } else { + s, err = decodeUTF16PoolString(p.data[off:]) + } + if err != nil { + return "", err + } + p.decoded += uint64(len(s)) + if p.decoded > stringPoolDecodeFactor*uint64(len(p.data)) { + return "", fmt.Errorf("%w: string pool decodes to more than %dx its size", ErrLimitExceeded, stringPoolDecodeFactor) + } + p.cache[i] = s + return s, nil +} + +func decodeUTF16PoolString(b []byte) (string, error) { + if len(b) < 2 { + return "", fmt.Errorf("%w: truncated string", errMalformedResource) + } + n := uint64(le.Uint16(b)) + b = b[2:] + if n&stringPoolHighBit16 != 0 { + if len(b) < 2 { + return "", fmt.Errorf("%w: truncated string", errMalformedResource) + } + n = (n&^stringPoolHighBit16)<<16 | uint64(le.Uint16(b)) + b = b[2:] + } + if n*2 > uint64(len(b)) { + return "", fmt.Errorf("%w: string longer than pool", errMalformedResource) + } + units := make([]uint16, n) + for i := range units { + units[i] = le.Uint16(b[i*2:]) + } + return string(utf16.Decode(units)), nil +} + +func decodeUTF8PoolString(b []byte) (string, error) { + // The UTF-16 length comes first and is not needed. + _, b, err := readUTF8PoolLength(b) + if err != nil { + return "", err + } + n, b, err := readUTF8PoolLength(b) + if err != nil { + return "", err + } + if n > uint64(len(b)) { + return "", fmt.Errorf("%w: string longer than pool", errMalformedResource) + } + return string(b[:n]), nil +} + +func readUTF8PoolLength(b []byte) (uint64, []byte, error) { + if len(b) < 1 { + return 0, nil, fmt.Errorf("%w: truncated string", errMalformedResource) + } + n := uint64(b[0]) + if n&stringPoolHighBit8 == 0 { + return n, b[1:], nil + } + if len(b) < 2 { + return 0, nil, fmt.Errorf("%w: truncated string", errMalformedResource) + } + return (n&^stringPoolHighBit8)<<8 | uint64(b[1]), b[2:], nil +} diff --git a/schema/appmeta.schema.json b/schema/appmeta.schema.json new file mode 100644 index 0000000..d9c4934 --- /dev/null +++ b/schema/appmeta.schema.json @@ -0,0 +1,113 @@ +{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "$id": "https://github.com/mobile-next/appmeta/schema/appmeta.schema.json", + "title": "appmeta output", + "description": "Metadata extracted from an Android .apk or iOS .ipa. String fields are empty when the binary does not declare them.", + "type": "object", + "additionalProperties": false, + "required": [ + "schemaVersion", "format", "platform", "bundleId", "name", "version", "buildNumber", + "minOsVersion", "targetOsVersion", "isSimulator", "isDebuggable", "deviceFamilies", + "architectures", "permissions", "signing", "icon", "warnings" + ], + "properties": { + "schemaVersion": { "const": 1 }, + "format": { "enum": ["apk", "ipa"] }, + "platform": { "enum": ["android", "ios"] }, + "bundleId": { + "description": "Android package name or iOS CFBundleIdentifier.", + "type": "string", + "minLength": 1 + }, + "name": { + "description": "User-visible app name: the default-locale android:label, or CFBundleDisplayName / CFBundleName.", + "type": "string" + }, + "version": { + "description": "android:versionName or CFBundleShortVersionString.", + "type": "string" + }, + "buildNumber": { + "description": "android:versionCode or CFBundleVersion.", + "type": "string" + }, + "minOsVersion": { + "description": "Minimum OS release, e.g. \"8.0\" (Android API 26 mapped to its release) or \"15.0\" (MinimumOSVersion). Unknown future Android API levels are reported as \"API \".", + "type": "string" + }, + "targetOsVersion": { + "description": "Android targetSdkVersion mapped to a release, or the iOS SDK the app was built with (DTPlatformVersion).", + "type": "string" + }, + "isSimulator": { + "description": "True for iOS builds that run only on the simulator. Always false for Android.", + "type": "boolean" + }, + "isDebuggable": { + "description": "android:debuggable, or get-task-allow in the iOS provisioning profile.", + "type": "boolean" + }, + "deviceFamilies": { + "type": "array", + "items": { "enum": ["phone", "tablet", "tv", "watch", "car", "vision"] }, + "uniqueItems": true + }, + "architectures": { + "description": "Android ABIs from lib/ (empty for pure-Java apps), or Mach-O architectures of the iOS executable.", + "type": "array", + "items": { "type": "string" }, + "uniqueItems": true + }, + "permissions": { + "description": "Android uses-permission names. Always empty for iOS.", + "type": "array", + "items": { "type": "string" } + }, + "signing": { + "description": "How the app is signed; null when it is unsigned (or, on iOS, has no provisioning profile). Android: \"debug\" when signed with the Android debug key (CN=Android Debug, O=Android, C=US), else \"release\"; teamId is empty and expiresAt null. iOS: the provisioning profile's type, team id and expiry. Signatures are not verified.", + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "teamId", "expiresAt"], + "properties": { + "type": { "enum": ["development", "ad-hoc", "enterprise", "app-store", "debug", "release"] }, + "teamId": { "type": "string" }, + "expiresAt": { + "oneOf": [{ "type": "null" }, { "type": "string", "format": "date-time" }] + } + } + } + ] + }, + "icon": { + "description": "App icon re-encoded as PNG, at most 512x512. Null when no raster icon could be extracted; a warning says why.", + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["contentType", "width", "height", "sha256"], + "properties": { + "contentType": { "const": "image/png" }, + "width": { "type": "integer", "minimum": 1, "maximum": 512 }, + "height": { "type": "integer", "minimum": 1, "maximum": 512 }, + "sha256": { "type": "string", "pattern": "^[0-9a-f]{64}$" }, + "base64": { + "description": "The PNG bytes. Omitted when the caller asked for no icon data.", + "type": "string", + "contentEncoding": "base64", + "contentMediaType": "image/png" + } + } + } + ] + }, + "warnings": { + "description": "Human-readable notes about data that could not be extracted.", + "type": "array", + "items": { "type": "string" } + } + } +} diff --git a/testdata/golden/apk-acme-shop.json b/testdata/golden/apk-acme-shop.json new file mode 100644 index 0000000..2a327a5 --- /dev/null +++ b/testdata/golden/apk-acme-shop.json @@ -0,0 +1,37 @@ +{ + "schemaVersion": 1, + "format": "apk", + "platform": "android", + "bundleId": "com.acme.shop", + "name": "Acme Shop", + "version": "4.2.0", + "buildNumber": "4201", + "minOsVersion": "8.0", + "targetOsVersion": "15", + "isSimulator": false, + "isDebuggable": false, + "deviceFamilies": [ + "phone", + "tablet" + ], + "architectures": [ + "arm64-v8a", + "armeabi-v7a" + ], + "permissions": [ + "android.permission.CAMERA", + "android.permission.INTERNET" + ], + "signing": { + "type": "release", + "teamId": "", + "expiresAt": null + }, + "icon": { + "contentType": "image/png", + "width": 192, + "height": 192, + "sha256": "" + }, + "warnings": [] +} diff --git a/testdata/golden/apk-adaptive-icon.json b/testdata/golden/apk-adaptive-icon.json new file mode 100644 index 0000000..eb45be1 --- /dev/null +++ b/testdata/golden/apk-adaptive-icon.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "format": "apk", + "platform": "android", + "bundleId": "com.acme.adaptive", + "name": "Adaptive", + "version": "4.2.0", + "buildNumber": "4201", + "minOsVersion": "8.0", + "targetOsVersion": "15", + "isSimulator": false, + "isDebuggable": false, + "deviceFamilies": [ + "phone", + "tablet" + ], + "architectures": [], + "permissions": [], + "signing": { + "type": "debug", + "teamId": "", + "expiresAt": null + }, + "icon": { + "contentType": "image/png", + "width": 108, + "height": 108, + "sha256": "" + }, + "warnings": [ + "adaptive icon rendered from foreground layer only" + ] +} diff --git a/testdata/golden/apk-minimal.json b/testdata/golden/apk-minimal.json new file mode 100644 index 0000000..c625741 --- /dev/null +++ b/testdata/golden/apk-minimal.json @@ -0,0 +1,22 @@ +{ + "schemaVersion": 1, + "format": "apk", + "platform": "android", + "bundleId": "com.acme.minimal", + "name": "Minimal", + "version": "4.2.0", + "buildNumber": "4201", + "minOsVersion": "8.0", + "targetOsVersion": "15", + "isSimulator": false, + "isDebuggable": false, + "deviceFamilies": [ + "phone", + "tablet" + ], + "architectures": [], + "permissions": [], + "signing": null, + "icon": null, + "warnings": [] +} diff --git a/testdata/golden/ipa-acme-shop.json b/testdata/golden/ipa-acme-shop.json new file mode 100644 index 0000000..8d92e9b --- /dev/null +++ b/testdata/golden/ipa-acme-shop.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "format": "ipa", + "platform": "ios", + "bundleId": "com.acme.shop", + "name": "Acme Shop", + "version": "4.2.0", + "buildNumber": "4201", + "minOsVersion": "15.0", + "targetOsVersion": "17.2", + "isSimulator": false, + "isDebuggable": true, + "deviceFamilies": [ + "phone", + "tablet" + ], + "architectures": [ + "arm64" + ], + "permissions": [], + "signing": { + "type": "development", + "teamId": "ACME123456", + "expiresAt": "2027-03-01T12:00:00Z" + }, + "icon": { + "contentType": "image/png", + "width": 180, + "height": 180, + "sha256": "" + }, + "warnings": [] +} diff --git a/testdata/golden/ipa-simulator.json b/testdata/golden/ipa-simulator.json new file mode 100644 index 0000000..c79c007 --- /dev/null +++ b/testdata/golden/ipa-simulator.json @@ -0,0 +1,26 @@ +{ + "schemaVersion": 1, + "format": "ipa", + "platform": "ios", + "bundleId": "com.acme.shop", + "name": "Acme Shop", + "version": "4.2.0", + "buildNumber": "4201", + "minOsVersion": "15.0", + "targetOsVersion": "17.2", + "isSimulator": true, + "isDebuggable": false, + "deviceFamilies": [ + "phone" + ], + "architectures": [ + "x86_64", + "arm64" + ], + "permissions": [], + "signing": null, + "icon": null, + "warnings": [ + "icon not extracted: it is only in Assets.car" + ] +}