From 0916883065a6d7f6f21f1f5cd368fa7d2bf2554a Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:31:58 +0200 Subject: [PATCH 01/17] chore: scaffold repository --- .github/workflows/ci.yml | 68 +++++++++++++ .gitignore | 2 + .golangci.yml | 1 + CODE_OF_CONDUCT.md | 128 +++++++++++++++++++++++++ CONTRIBUTING.md | 46 +++++++++ LICENSE | 201 +++++++++++++++++++++++++++++++++++++++ Makefile | 15 +++ NOTICE | 4 + README.md | 10 ++ SECURITY.md | 17 ++++ go.mod | 3 + 11 files changed, 495 insertions(+) create mode 100644 .github/workflows/ci.yml create mode 100644 .gitignore create mode 100644 .golangci.yml create mode 100644 CODE_OF_CONDUCT.md create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 Makefile create mode 100644 NOTICE create mode 100644 SECURITY.md create mode 100644 go.mod diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..362d812 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,68 @@ +name: ci + +on: + push: + branches: + - main + 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: [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 . 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/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..2b9d6b5 --- /dev/null +++ b/Makefile @@ -0,0 +1,15 @@ +.PHONY: all test lint fmt vulncheck + +lint: + $(shell go env GOPATH)/bin/golangci-lint run + +fmt: + go fmt ./... + $(shell go env GOPATH)/bin/goimports -w . + +test: + go test -race ./... + +vulncheck: + go build -o appmeta ./cmd/appmeta + $(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..e73fa23 100644 --- a/README.md +++ b/README.md @@ -1 +1,11 @@ # 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). 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/go.mod b/go.mod new file mode 100644 index 0000000..1ca348e --- /dev/null +++ b/go.mod @@ -0,0 +1,3 @@ +module github.com/mobile-next/appmeta + +go 1.25 From 95b09edc0f5cc04ea5b1de640d5f1965ea803341 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:36:48 +0200 Subject: [PATCH 02/17] feat: add Info type, schema and golden harness --- apk.go | 9 +++ appmeta.go | 122 ++++++++++++++++++++++++++++ appmeta_test.go | 40 +++++++++ archive.go | 161 +++++++++++++++++++++++++++++++++++++ fixtures_zip_test.go | 45 +++++++++++ go.mod | 4 + go.sum | 6 ++ golden_test.go | 109 +++++++++++++++++++++++++ ipa.go | 28 +++++++ limits.go | 61 ++++++++++++++ schema/appmeta.schema.json | 113 ++++++++++++++++++++++++++ 11 files changed, 698 insertions(+) create mode 100644 apk.go create mode 100644 appmeta.go create mode 100644 appmeta_test.go create mode 100644 archive.go create mode 100644 fixtures_zip_test.go create mode 100644 go.sum create mode 100644 golden_test.go create mode 100644 ipa.go create mode 100644 limits.go create mode 100644 schema/appmeta.schema.json diff --git a/apk.go b/apk.go new file mode 100644 index 0000000..015086f --- /dev/null +++ b/apk.go @@ -0,0 +1,9 @@ +package appmeta + +import "errors" + +const androidManifestPath = "AndroidManifest.xml" + +func parseAPK(a *archive) (*Info, error) { + return nil, errors.New("appmeta: apk not implemented") +} diff --git a/appmeta.go b/appmeta.go new file mode 100644 index 0000000..f31980d --- /dev/null +++ b/appmeta.go @@ -0,0 +1,122 @@ +// 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. +// +// Parse takes no context. Callers that need a deadline should pass a ReaderAt +// that fails once their context is done; the limits bound the CPU work done +// between reads. +package appmeta + +import ( + "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 + +// 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"` // "apk" or "ipa" + Platform string `json:"platform"` // "android" or "ios" + 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 the provisioning profile an iOS app was signed with. +type Signing struct { + Type string `json:"type"` // "development", "ad-hoc", "enterprise" or "app-store" + 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") +) + +// 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 *Info, err error) { + // The host must survive any input, so a parser bug becomes an error. + defer func() { + if p := recover(); p != nil { + info = nil + err = fmt.Errorf("appmeta: internal error: %v", p) + } + }() + + cfg := config{limits: DefaultLimits()} + for _, opt := range opts { + opt(&cfg) + } + + a, err := openArchive(r, size, cfg.limits) + if err != nil { + return nil, err + } + + switch { + case a.has(androidManifestPath): + info, err = parseAPK(a) + case findAppBundle(a) != "": + info, err = parseIPA(a) + default: + return nil, ErrUnsupportedFormat + } + if err != nil { + return nil, err + } + info.SchemaVersion = SchemaVersion + fillEmptyLists(info) + return info, nil +} + +// 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..d7d4387 --- /dev/null +++ b/archive.go @@ -0,0 +1,161 @@ +package appmeta + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "errors" + "fmt" + "io" + "io/fs" +) + +const ( + eocdLen = 22 + maxZipCommentLen = 65535 + zip64LocatorLen = 20 + zip64EOCDLen = 56 + zip16BitEntryCountMax = 0xFFFF +) + +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 { + files map[string]*zip.File + limits Limits + totalRead int64 +} + +func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { + count, err := declaredEntryCount(r, size) + if err != nil { + return nil, err + } + // archive/zip allocates per declared entry, so check the count first. + if count > uint64(limits.MaxEntries) { + return nil, fmt.Errorf("%w: archive declares %d entries, max %d", ErrLimitExceeded, count, 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) + } + + 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{files: files, limits: limits}, nil +} + +// declaredEntryCount reads the entry count from the end of central directory +// record, following the zip64 locator when the 16-bit field overflows. +func declaredEntryCount(r io.ReaderAt, size int64) (uint64, error) { + tailLen := min(size, eocdLen+maxZipCommentLen) + if tailLen < eocdLen { + return 0, ErrUnsupportedFormat + } + tail, err := readAt(r, size-tailLen, tailLen) + if err != nil { + return 0, err + } + i := bytes.LastIndex(tail, eocdSignature) + if i < 0 || len(tail)-i < eocdLen { + return 0, ErrUnsupportedFormat + } + count := uint64(binary.LittleEndian.Uint16(tail[i+10:])) + if count != zip16BitEntryCountMax { + return count, nil + } + + locatorOffset := size - tailLen + int64(i) - zip64LocatorLen + if locatorOffset < 0 { + return count, nil + } + locator, err := readAt(r, locatorOffset, zip64LocatorLen) + if err != nil { + return 0, err + } + if !bytes.Equal(locator[:4], zip64LocatorSignature) { + return count, nil + } + recordOffset := binary.LittleEndian.Uint64(locator[8:]) + if recordOffset > uint64(size-zip64EOCDLen) { + return 0, fmt.Errorf("%w: zip64 record out of bounds", ErrUnsupportedFormat) + } + record, err := readAt(r, int64(recordOffset), zip64EOCDLen) + if err != nil { + return 0, err + } + if !bytes.Equal(record[:4], zip64EOCDSignature) { + return 0, fmt.Errorf("%w: bad zip64 record", ErrUnsupportedFormat) + } + return binary.LittleEndian.Uint64(record[32:]), 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 +} + +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/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/go.mod b/go.mod index 1ca348e..be7bf9a 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,7 @@ module github.com/mobile-next/appmeta go 1.25 + +require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + +require golang.org/x/text v0.14.0 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..5b447c8 --- /dev/null +++ b/go.sum @@ -0,0 +1,6 @@ +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/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= +golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= +golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= diff --git a/golden_test.go b/golden_test.go new file mode 100644 index 0000000..147ef69 --- /dev/null +++ b/golden_test.go @@ -0,0 +1,109 @@ +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 nil +} + +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/ipa.go b/ipa.go new file mode 100644 index 0000000..079161e --- /dev/null +++ b/ipa.go @@ -0,0 +1,28 @@ +package appmeta + +import ( + "errors" + "strings" +) + +// 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) { + return nil, errors.New("appmeta: ipa not implemented") +} 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/schema/appmeta.schema.json b/schema/appmeta.schema.json new file mode 100644 index 0000000..c0253f3 --- /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": "iOS provisioning profile summary; null for Android and for unsigned or simulator builds.", + "oneOf": [ + { "type": "null" }, + { + "type": "object", + "additionalProperties": false, + "required": ["type", "teamId", "expiresAt"], + "properties": { + "type": { "enum": ["development", "ad-hoc", "enterprise", "app-store"] }, + "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" } + } + } +} From 0a8231c66f438cca4805ca783a20c7aec4412ad8 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:40:28 +0200 Subject: [PATCH 03/17] feat: parse the APK binary manifest --- apk.go | 170 +++++++++++++++++++++++- apk_test.go | 151 +++++++++++++++++++++ axml.go | 167 ++++++++++++++++++++++++ fixtures_apk_test.go | 41 ++++++ fixtures_axml_test.go | 216 +++++++++++++++++++++++++++++++ golden_test.go | 4 +- resource_chunk.go | 184 ++++++++++++++++++++++++++ testdata/golden/apk-minimal.json | 22 ++++ 8 files changed, 952 insertions(+), 3 deletions(-) create mode 100644 apk_test.go create mode 100644 axml.go create mode 100644 fixtures_apk_test.go create mode 100644 fixtures_axml_test.go create mode 100644 resource_chunk.go create mode 100644 testdata/golden/apk-minimal.json diff --git a/apk.go b/apk.go index 015086f..8504ebc 100644 --- a/apk.go +++ b/apk.go @@ -1,9 +1,175 @@ package appmeta -import "errors" +import ( + "fmt" + "slices" + "strconv" + "strings" +) const androidManifestPath = "AndroidManifest.xml" +// 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 +) + +// 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 +} + func parseAPK(a *archive) (*Info, error) { - return nil, errors.New("appmeta: apk not implemented") + 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: "apk", + Platform: "android", + 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, + } + info.Version = m.versionName.text() + info.Name = m.label.text() + if info.Name == "" { + info.Name = m.packageName + } + return info, nil +} + +func parseManifest(data []byte, maxDepth int) (*apkManifest, error) { + m := &apkManifest{} + err := parseAXML(data, maxDepth, func(path []string, attrs []xmlAttr) { + switch { + case pathIs(path, "manifest"): + 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") + case pathIs(path, "manifest", "uses-sdk"): + m.minSDK, _ = findAttr(attrs, attrMinSDKVersion, "minSdkVersion") + m.targetSDK, _ = findAttr(attrs, attrTargetSDKVersion, "targetSdkVersion") + case pathIs(path, "manifest", "application"): + 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 + } + case pathIs(path, "manifest", "uses-permission"), + pathIs(path, "manifest", "uses-permission-sdk-23"), + pathIs(path, "manifest", "uses-permission-sdk-m"): + if a, ok := findAttr(attrs, attrName, "name"); ok && a.text() != "" && !slices.Contains(m.permissions, a.text()) { + m.permissions = append(m.permissions, a.text()) + } + case pathIs(path, "manifest", "uses-feature"): + 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) + } + }) + if err != nil { + return nil, err + } + return m, nil +} + +// 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/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/fixtures_apk_test.go b/fixtures_apk_test.go new file mode 100644 index 0000000..5fc75e4 --- /dev/null +++ b/fixtures_apk_test.go @@ -0,0 +1,41 @@ +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"), + ))) +} 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/golden_test.go b/golden_test.go index 147ef69..292cccc 100644 --- a/golden_test.go +++ b/golden_test.go @@ -21,7 +21,9 @@ type goldenFixture struct { // goldenFixtures lists every synthetic app whose full output is pinned in // testdata/golden/.json. func goldenFixtures() []goldenFixture { - return nil + return []goldenFixture{ + {"apk-minimal", buildMinimalAPK}, + } } func TestEveryFixtureMatchesItsGoldenFileAndTheSchema(t *testing.T) { diff --git a/resource_chunk.go b/resource_chunk.go new file mode 100644 index 0000000..9c10198 --- /dev/null +++ b/resource_chunk.go @@ -0,0 +1,184 @@ +package appmeta + +import ( + "encoding/binary" + "errors" + "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 = errors.New("appmeta: malformed android resource") + +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/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": [] +} From 60e08c92c2d8f97bb2c7c4f57c3216c379ff1b31 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:44:13 +0200 Subject: [PATCH 04/17] feat: resolve APK label and icon from resources --- apk.go | 158 +++++++++++++- arsc.go | 271 +++++++++++++++++++++++++ arsc_test.go | 127 ++++++++++++ fixtures_apk_test.go | 84 ++++++++ fixtures_arsc_test.go | 199 ++++++++++++++++++ fixtures_image_test.go | 106 ++++++++++ go.mod | 7 +- go.sum | 4 + golden_test.go | 2 + icon.go | 117 +++++++++++ testdata/golden/apk-acme-shop.json | 33 +++ testdata/golden/apk-adaptive-icon.json | 29 +++ 12 files changed, 1132 insertions(+), 5 deletions(-) create mode 100644 arsc.go create mode 100644 arsc_test.go create mode 100644 fixtures_arsc_test.go create mode 100644 fixtures_image_test.go create mode 100644 icon.go create mode 100644 testdata/golden/apk-acme-shop.json create mode 100644 testdata/golden/apk-adaptive-icon.json diff --git a/apk.go b/apk.go index 8504ebc..93e1ab9 100644 --- a/apk.go +++ b/apk.go @@ -7,7 +7,10 @@ import ( "strings" ) -const androidManifestPath = "AndroidManifest.xml" +const ( + androidManifestPath = "AndroidManifest.xml" + androidResourcesPath = "resources.arsc" +) // Android framework attribute ids (android.R.attr). const ( @@ -20,6 +23,7 @@ const ( attrVersionName = 0x0101021c attrTargetSDKVersion = 0x01010270 attrRequired = 0x0101028e + attrDrawable = 0x01010199 ) // androidReleases maps API levels to the release users know them by. @@ -74,14 +78,57 @@ func parseAPK(a *archive) (*Info, error) { Architectures: androidABIs(a), Permissions: m.permissions, } - info.Version = m.versionName.text() - info.Name = m.label.text() + res := &apkResources{archive: a} + info.Version = res.text(m.versionName, "android:versionName", info) + info.Name = res.text(m.label, "android:label", info) if info.Name == "" { info.Name = m.packageName } + extractAPKIcon(res, m.icon, info) 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 + data, err := r.archive.read(androidResourcesPath) + if err == nil { + r.table, err = parseResourceTable(data) + } + if err != nil { + r.err = fmt.Errorf("%s: %w", androidResourcesPath, err) + } + } + return r.table, r.err +} + +// text returns an attribute's text, resolving references. Failures become +// warnings: the rest of the metadata is still useful. +func (r *apkResources) text(attr xmlAttr, field string, info *Info) string { + if !attr.isReference() { + return attr.text() + } + table, err := r.get() + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("%s %s not resolved: %v", field, formatResID(attr.data), err)) + return "" + } + s, ok := table.resolveText(attr.data) + if !ok { + info.Warnings = append(info.Warnings, 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{} err := parseAXML(data, maxDepth, func(path []string, attrs []xmlAttr) { @@ -126,6 +173,111 @@ func parseManifest(data []byte, maxDepth int) (*apkManifest, error) { return m, nil } +func extractAPKIcon(res *apkResources, attr xmlAttr, info *Info) { + if !attr.isReference() { + return + } + table, err := res.get() + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %v", err)) + return + } + files := table.resolveFiles(attr.data) + icon, err := firstDecodableRaster(res.archive, files) + if icon != nil { + info.Icon = icon + return + } + for _, f := range files { + if !strings.HasSuffix(f.path, ".xml") { + continue + } + foreground, ok := adaptiveIconForeground(res.archive, f.path) + if !ok { + continue + } + if icon, _ := firstDecodableRaster(res.archive, table.resolveFiles(foreground)); icon != nil { + info.Icon = icon + info.Warnings = append(info.Warnings, "adaptive icon rendered from foreground layer only") + return + } + } + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %v", err)) + return + } + info.Warnings = append(info.Warnings, "icon not extracted: no raster image found (vector drawables are not rendered)") +} + +// 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 { 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/fixtures_apk_test.go b/fixtures_apk_test.go index 5fc75e4..b763709 100644 --- a/fixtures_apk_test.go +++ b/fixtures_apk_test.go @@ -39,3 +39,87 @@ func buildMinimalAPK(t testing.TB) []byte { 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: resource label, icons at two +// densities plus an adaptive icon, permissions and two ABIs. +func buildAcmeShopAPK(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. +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)}, + ) +} + +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_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_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/go.mod b/go.mod index be7bf9a..1e851cb 100644 --- a/go.mod +++ b/go.mod @@ -1,7 +1,10 @@ module github.com/mobile-next/appmeta -go 1.25 +go 1.26.0 require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 -require golang.org/x/text v0.14.0 // indirect +require ( + golang.org/x/image v0.46.0 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index 5b447c8..19f92d8 100644 --- a/go.sum +++ b/go.sum @@ -2,5 +2,9 @@ github.com/dlclark/regexp2 v1.11.0 h1:G/nrcoOa7ZXlpoa/91N3X7mM3r8eIlMBBJZvsz/mxK github.com/dlclark/regexp2 v1.11.0/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= 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= +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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= +golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI= +golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E= diff --git a/golden_test.go b/golden_test.go index 292cccc..86690f3 100644 --- a/golden_test.go +++ b/golden_test.go @@ -23,6 +23,8 @@ type goldenFixture struct { func goldenFixtures() []goldenFixture { return []goldenFixture{ {"apk-minimal", buildMinimalAPK}, + {"apk-acme-shop", buildAcmeShopAPK}, + {"apk-adaptive-icon", buildAdaptiveIconAPK}, } } diff --git a/icon.go b/icon.go new file mode 100644 index 0000000..4af7ab9 --- /dev/null +++ b/icon.go @@ -0,0 +1,117 @@ +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): + 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, 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/testdata/golden/apk-acme-shop.json b/testdata/golden/apk-acme-shop.json new file mode 100644 index 0000000..8b2af78 --- /dev/null +++ b/testdata/golden/apk-acme-shop.json @@ -0,0 +1,33 @@ +{ + "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": 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..6f2cdf9 --- /dev/null +++ b/testdata/golden/apk-adaptive-icon.json @@ -0,0 +1,29 @@ +{ + "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": null, + "icon": { + "contentType": "image/png", + "width": 108, + "height": 108, + "sha256": "" + }, + "warnings": [ + "adaptive icon rendered from foreground layer only" + ] +} From b0ee1c9519b7c3100e6405e09053a1e89b70f997 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:48:50 +0200 Subject: [PATCH 05/17] feat: parse IPA Info.plist and CgBI icons --- cgbi.go | 172 +++++++++++++++++ fixtures_ipa_test.go | 152 +++++++++++++++ go.mod | 1 + go.sum | 5 + golden_test.go | 1 + icon.go | 4 +- ipa.go | 140 +++++++++++++- ipa_test.go | 165 +++++++++++++++++ plist.go | 285 +++++++++++++++++++++++++++++ testdata/golden/ipa-acme-shop.json | 27 +++ 10 files changed, 949 insertions(+), 3 deletions(-) create mode 100644 cgbi.go create mode 100644 fixtures_ipa_test.go create mode 100644 ipa_test.go create mode 100644 plist.go create mode 100644 testdata/golden/ipa-acme-shop.json diff --git a/cgbi.go b/cgbi.go new file mode 100644 index 0000000..04954d1 --- /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 = errors.New("appmeta: malformed CgBI png") + +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/fixtures_ipa_test.go b/fixtures_ipa_test.go new file mode 100644 index 0000000..5290de5 --- /dev/null +++ b/fixtures_ipa_test.go @@ -0,0 +1,152 @@ +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()) +} + +// buildAcmeShopIPA is a typical device build with CgBI icons at two scales. +func buildAcmeShopIPA(t testing.TB) []byte { + return buildIPA(t, binaryPlist(t, acmeInfoPlist(nil)), + 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")}, + ) +} diff --git a/go.mod b/go.mod index 1e851cb..20245e5 100644 --- a/go.mod +++ b/go.mod @@ -7,4 +7,5 @@ require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 require ( golang.org/x/image v0.46.0 // indirect golang.org/x/text v0.42.0 // indirect + howett.net/plist v1.0.1 // indirect ) diff --git a/go.sum b/go.sum index 19f92d8..aa35036 100644 --- a/go.sum +++ b/go.sum @@ -1,5 +1,6 @@ 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/jessevdk/go-flags v1.4.0/go.mod h1:4FA24M0QyGHXBuZZK/XkWh8h0e1EYbRYJSGM75WSRxI= 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= golang.org/x/image v0.46.0 h1:b1+oYj0Jbp6K5MDT4i4/eZpYlk3V8SJhhDKh6LBHAyQ= @@ -8,3 +9,7 @@ golang.org/x/text v0.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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 index 86690f3..40ff162 100644 --- a/golden_test.go +++ b/golden_test.go @@ -25,6 +25,7 @@ func goldenFixtures() []goldenFixture { {"apk-minimal", buildMinimalAPK}, {"apk-acme-shop", buildAcmeShopAPK}, {"apk-adaptive-icon", buildAdaptiveIconAPK}, + {"ipa-acme-shop", buildAcmeShopIPA}, } } diff --git a/icon.go b/icon.go index 4af7ab9..1f08b6c 100644 --- a/icon.go +++ b/icon.go @@ -32,6 +32,8 @@ type imageCodec struct { // 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)) }, @@ -64,7 +66,7 @@ func imageSize(data []byte) (int, int, error) { return cfg.Width, cfg.Height, nil } -// encodeIcon decodes a PNG, JPEG or WebP and re-encodes +// 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) { diff --git a/ipa.go b/ipa.go index 079161e..243eba4 100644 --- a/ipa.go +++ b/ipa.go @@ -1,10 +1,21 @@ package appmeta import ( - "errors" + "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 := "" @@ -24,5 +35,130 @@ func findAppBundle(a *archive) string { } func parseIPA(a *archive) (*Info, error) { - return nil, errors.New("appmeta: ipa not implemented") + 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: "ipa", + Platform: "ios", + 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), + } + extractIPAIcon(a, bundle, plist, info) + return info, nil +} + +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, info *Info) { + var best string + bestArea := 0 + for _, path := range iconCandidates(a, bundle, iconBaseNames(plist)) { + data, err := a.read(path) + if err != nil { + continue + } + w, h, err := imageSize(data) + if err == nil && w*h > bestArea { + best, bestArea = path, w*h + } + } + if best == "" { + primary := plistDict(plistDict(plist, "CFBundleIcons"), "CFBundlePrimaryIcon") + if a.has(bundle+"Assets.car") || plistString(primary, "CFBundleIconName") != "" { + info.Warnings = append(info.Warnings, "icon not extracted: it is only in Assets.car") + return + } + info.Warnings = append(info.Warnings, "icon not extracted: no icon file found") + return + } + data, err := a.read(best) + if err == nil { + info.Icon, err = encodeIcon(data, a.limits.MaxIconPixels) + } + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %s: %v", strings.TrimPrefix(best, bundle), err)) + } +} + +// 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/plist.go b/plist.go new file mode 100644 index 0000000..711068e --- /dev/null +++ b/plist.go @@ -0,0 +1,285 @@ +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 = errors.New("appmeta: malformed plist") +) + +// 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, if that fails, 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 { + 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 +} + +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/testdata/golden/ipa-acme-shop.json b/testdata/golden/ipa-acme-shop.json new file mode 100644 index 0000000..77d4b88 --- /dev/null +++ b/testdata/golden/ipa-acme-shop.json @@ -0,0 +1,27 @@ +{ + "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": false, + "deviceFamilies": [ + "phone", + "tablet" + ], + "architectures": [], + "permissions": [], + "signing": null, + "icon": { + "contentType": "image/png", + "width": 180, + "height": 180, + "sha256": "" + }, + "warnings": [] +} From dee5241f642a0800c4b43941a7a27a722c5b507b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:50:20 +0200 Subject: [PATCH 06/17] feat: read iOS signing profile and Mach-O --- fixtures_ipa_test.go | 22 +++- fixtures_macho_test.go | 115 ++++++++++++++++++++ golden_test.go | 1 + ipa.go | 41 +++++++ macho.go | 167 +++++++++++++++++++++++++++++ macho_test.go | 94 ++++++++++++++++ mobileprovision.go | 60 +++++++++++ testdata/golden/ipa-acme-shop.json | 12 ++- testdata/golden/ipa-simulator.json | 26 +++++ 9 files changed, 534 insertions(+), 4 deletions(-) create mode 100644 fixtures_macho_test.go create mode 100644 macho.go create mode 100644 macho_test.go create mode 100644 mobileprovision.go create mode 100644 testdata/golden/ipa-simulator.json diff --git a/fixtures_ipa_test.go b/fixtures_ipa_test.go index 5290de5..7d9d30e 100644 --- a/fixtures_ipa_test.go +++ b/fixtures_ipa_test.go @@ -142,11 +142,31 @@ func writePNGChunk(w *bytes.Buffer, typ string, body []byte) { _ = binary.Write(w, binary.BigEndian, crc.Sum32()) } -// buildAcmeShopIPA is a typical device build with CgBI icons at two scales. +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/golden_test.go b/golden_test.go index 40ff162..4978cb0 100644 --- a/golden_test.go +++ b/golden_test.go @@ -26,6 +26,7 @@ func goldenFixtures() []goldenFixture { {"apk-acme-shop", buildAcmeShopAPK}, {"apk-adaptive-icon", buildAdaptiveIconAPK}, {"ipa-acme-shop", buildAcmeShopIPA}, + {"ipa-simulator", buildSimulatorIPA}, } } diff --git a/ipa.go b/ipa.go index 243eba4..d581c8c 100644 --- a/ipa.go +++ b/ipa.go @@ -61,10 +61,51 @@ func parseIPA(a *archive) (*Info, error) { IsSimulator: strings.Contains(strings.ToLower(plistString(plist, "DTPlatformName")), "simulator"), DeviceFamilies: deviceFamilies(plist), } + sniffExecutable(a, bundle, plistString(plist, "CFBundleExecutable"), info) + readProvisioningProfile(a, bundle, info) extractIPAIcon(a, bundle, plist, info) return info, nil } +// sniffExecutable prefers the Mach-O platform over DTPlatformName, which is +// only what Xcode wrote into Info.plist. +func sniffExecutable(a *archive, bundle, executable string, info *Info) { + if executable == "" { + info.Warnings = append(info.Warnings, "architectures unknown: Info.plist has no CFBundleExecutable") + return + } + data, err := a.readPrefix(bundle+executable, machOPrefixLen) + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("architectures unknown: %v", err)) + return + } + summary, err := sniffMachO(data) + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("architectures unknown: %s: %v", executable, err)) + return + } + info.Architectures = summary.architectures + if summary.hasPlatform { + info.IsSimulator = summary.isSimulator + } +} + +// readProvisioningProfile leaves signing null when there is no profile, as +// for simulator builds and App Store downloads. +func readProvisioningProfile(a *archive, bundle string, info *Info) { + path := bundle + "embedded.mobileprovision" + if !a.has(path) { + return + } + data, err := a.read(path) + if err == nil { + info.Signing, info.IsDebuggable, err = parseProvisioningProfile(data, a.limits.MaxDepth) + } + if err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("signing unknown: embedded.mobileprovision: %v", err)) + } +} + func firstNonEmpty(values ...string) string { for _, v := range values { if v != "" { 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..816655b --- /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 "enterprise" + case len(plistStrings(profile, "ProvisionedDevices")) > 0 && getTaskAllow: + return "development" + case len(plistStrings(profile, "ProvisionedDevices")) > 0: + return "ad-hoc" + } + return "app-store" +} diff --git a/testdata/golden/ipa-acme-shop.json b/testdata/golden/ipa-acme-shop.json index 77d4b88..8d92e9b 100644 --- a/testdata/golden/ipa-acme-shop.json +++ b/testdata/golden/ipa-acme-shop.json @@ -9,14 +9,20 @@ "minOsVersion": "15.0", "targetOsVersion": "17.2", "isSimulator": false, - "isDebuggable": false, + "isDebuggable": true, "deviceFamilies": [ "phone", "tablet" ], - "architectures": [], + "architectures": [ + "arm64" + ], "permissions": [], - "signing": null, + "signing": { + "type": "development", + "teamId": "ACME123456", + "expiresAt": "2027-03-01T12:00:00Z" + }, "icon": { "contentType": "image/png", "width": 180, 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" + ] +} From ec010be9a378f78614d6b03d0ffbff802d8510a2 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 09:59:26 +0200 Subject: [PATCH 07/17] test: add limit tests and fuzz targets --- .github/workflows/ci.yml | 4 +- appmeta.go | 7 ++- fuzz_test.go | 81 +++++++++++++++++++++++++++ limits_test.go | 115 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 204 insertions(+), 3 deletions(-) create mode 100644 fuzz_test.go create mode 100644 limits_test.go diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 362d812..d17846d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: strategy: fail-fast: false matrix: - target: [FuzzParse] + target: [FuzzAXML, FuzzResourceTable, FuzzPlist, FuzzIcon, FuzzMachO, FuzzParse] steps: - uses: actions/checkout@v5 with: @@ -65,4 +65,4 @@ jobs: with: go-version-file: go.mod cache: true - - run: go test -run '^$' -fuzz '^${{ matrix.target }}$' -fuzztime 60s . + - run: go test -run '^$' -fuzz '^${{ matrix.target }}$' -fuzztime 60s -fuzzminimizetime 5s . diff --git a/appmeta.go b/appmeta.go index f31980d..4248df8 100644 --- a/appmeta.go +++ b/appmeta.go @@ -82,12 +82,17 @@ func Parse(r io.ReaderAt, size int64, opts ...Option) (info *Info, err error) { for _, opt := range opts { opt(&cfg) } + return parse(r, size, cfg.limits) +} - a, err := openArchive(r, size, cfg.limits) +// 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) diff --git a/fuzz_test.go b/fuzz_test.go new file mode 100644 index 0000000..b955983 --- /dev/null +++ b/fuzz_test.go @@ -0,0 +1,81 @@ +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(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) + }) +} 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 +} From de2a10ff65bcd5d0e5ad80ab48ed4b5276b113eb Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:02:00 +0200 Subject: [PATCH 08/17] feat: add minimal appmeta CLI --- cmd/appmeta/main.go | 66 ++++++++++++++++++++++++++++++++++++++++ cmd/appmeta/main_test.go | 51 +++++++++++++++++++++++++++++++ go.mod | 10 +++--- go.sum | 2 -- 4 files changed, 123 insertions(+), 6 deletions(-) create mode 100644 cmd/appmeta/main.go create mode 100644 cmd/appmeta/main_test.go diff --git a/cmd/appmeta/main.go b/cmd/appmeta/main.go new file mode 100644 index 0000000..3c6ecec --- /dev/null +++ b/cmd/appmeta/main.go @@ -0,0 +1,66 @@ +// Command appmeta prints the metadata of an .apk or .ipa as JSON. +package main + +import ( + "encoding/json" + "errors" + "flag" + "fmt" + "io" + "os" + + "github.com/mobile-next/appmeta" +) + +func main() { + iconPath := flag.String("icon", "", "write the icon PNG to this `file`") + noIcon := flag.Bool("no-icon", false, "omit the base64 icon from the JSON") + flag.Usage = func() { + _, _ = fmt.Fprintf(flag.CommandLine.Output(), "usage: appmeta [--icon out.png] [--no-icon] app.apk|app.ipa\n") + flag.PrintDefaults() + } + flag.Parse() + if flag.NArg() != 1 { + flag.Usage() + os.Exit(2) + } + if err := run(os.Stdout, flag.Arg(0), *iconPath, *noIcon); err != nil { + printJSON(os.Stdout, map[string]string{"error": err.Error()}) + os.Exit(1) + } +} + +func run(out io.Writer, path, iconPath string, noIcon bool) 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 iconPath != "" { + if info.Icon == nil { + return errors.New("the app has no extractable icon") + } + if err := os.WriteFile(iconPath, info.Icon.PNG, 0o644); err != nil { + return err + } + } + if noIcon && info.Icon != nil { + info.Icon.PNG = nil + } + printJSON(out, info) + return nil +} + +func printJSON(out io.Writer, v any) { + enc := json.NewEncoder(out) + enc.SetIndent("", " ") + _ = enc.Encode(v) +} diff --git a/cmd/appmeta/main_test.go b/cmd/appmeta/main_test.go new file mode 100644 index 0000000..4d89a8b --- /dev/null +++ b/cmd/appmeta/main_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "archive/zip" + "bytes" + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestTheCLIPrintsAnErrorForAFileThatIsNotAnApp(t *testing.T) { + path := filepath.Join(t.TempDir(), "notes.zip") + writeZipWithOneFile(t, path, "notes.txt") + var out bytes.Buffer + err := run(&out, path, "", false) + if err == nil { + t.Fatal("want an error") + } +} + +func TestTheCLIFailsForAMissingFile(t *testing.T) { + var out bytes.Buffer + if err := run(&out, filepath.Join(t.TempDir(), "missing.apk"), "", false); err == nil { + t.Fatal("want an error") + } +} + +func TestErrorsArePrintedAsJSON(t *testing.T) { + var out bytes.Buffer + printJSON(&out, map[string]string{"error": "boom"}) + 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 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) + } +} diff --git a/go.mod b/go.mod index 20245e5..3a1f23b 100644 --- a/go.mod +++ b/go.mod @@ -2,10 +2,12 @@ module github.com/mobile-next/appmeta go 1.26.0 -require github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 +toolchain go1.26.6 require ( - golang.org/x/image v0.46.0 // indirect - golang.org/x/text v0.42.0 // indirect - howett.net/plist v1.0.1 // indirect + github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + golang.org/x/image v0.46.0 + howett.net/plist v1.0.1 ) + +require golang.org/x/text v0.42.0 // indirect diff --git a/go.sum b/go.sum index aa35036..377b1ea 100644 --- a/go.sum +++ b/go.sum @@ -5,8 +5,6 @@ github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 h1:1EYB5IzjZawrrnELUi78f9fPu57Hu github.com/santhosh-tekuri/jsonschema/v6 v6.0.3/go.mod h1:JXeL+ps8p7/KNMjDQk3TCwPpBy0wYklyWTfbkIzdIFU= 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.14.0 h1:ScX5w1eTa3QqT8oi6+ziP7dTV1S2+ALU0bI+0zXKWiQ= -golang.org/x/text v0.14.0/go.mod h1:18ZOQIKpY8NJVqYksKHtTdi31H5itFRjB5/qKTNYzSU= 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= From 9655e8cd95fc126b2fcf3b0167c75038032f9bd8 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:02:25 +0200 Subject: [PATCH 09/17] docs: add library survey and usage --- README.md | 30 ++++++++++++++++++++++++ docs/decisions.md | 59 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 89 insertions(+) create mode 100644 docs/decisions.md diff --git a/README.md b/README.md index e73fa23..9f3e5fb 100644 --- a/README.md +++ b/README.md @@ -9,3 +9,33 @@ version, build number, minimum OS, icon, signing, architectures and more. - 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)`. 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/docs/decisions.md b/docs/decisions.md new file mode 100644 index 0000000..cf8e408 --- /dev/null +++ b/docs/decisions.md @@ -0,0 +1,59 @@ +# 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). | +| 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. | +| 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. + +There is no context/deadline parameter (the PRD signature has none). A host +that needs a deadline passes a ReaderAt that fails once its context is done. + +## 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. From 38f9ade51b100b63126f4d02c6db2e19ee21ef7a Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:06:16 +0200 Subject: [PATCH 10/17] feat: build the CLI with cobra --- README.md | 2 +- a.png | Bin 0 -> 28554 bytes cmd/appmeta/main.go | 48 ++++++++++++++++++++++++--------------- cmd/appmeta/main_test.go | 45 +++++++++++++++++++++++++++++------- docs/decisions.md | 1 + go.mod | 7 +++++- go.sum | 9 ++++++++ 7 files changed, 84 insertions(+), 28 deletions(-) create mode 100644 a.png diff --git a/README.md b/README.md index 9f3e5fb..63b89d7 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ defaults. The JSON shape is documented in 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 +appmeta -o icon.png app.apk # also write the icon (--icon) ``` On failure it prints `{"error": "..."}` and exits non-zero. diff --git a/a.png b/a.png new file mode 100644 index 0000000000000000000000000000000000000000..5547257e7590a859aaa5aaa54bf12167d442d178 GIT binary patch literal 28554 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

Wm z=e|RpAAltDW{W&DIG#);i%Z;!Nkj;ajf5|ryQ*u3)v6#acW@T@%)mi(eCh2(ln6nXGmhr)CxlQV`qR6<|Ah~JOc5kDJ*W)f&GpOH z)i3{p-gW+@nEhQo7BTTzPH-vP$CkEXCjv5cwe|DBA9r_5}vxd5_+~(a+RL#0~JmH z;dNRI7%*{YdyTW>!gztu#WtbA>)dD+1_Ad*&nRPnyZnPD&x&&2$|_r(JJL1Y{mMm4 zrPEyP6fIKk>n&89F|%kYprLDo7&PdQ$D^seyN?x@y0+f6I!_5g8PyG)=@Ui}&j~;i z%_hntj1Z%gDkgFDx`xXa`g|phfInuj$i5P1N83KMVLZsmTDiHBeyrb38Z zP8+xOE89;${f48#2O&K3^#iiUoH#t(P*s0s;8OX$4dRK+bPPcT3p22Cr=70r7$XSb zV86dmHXuk`S(SaHKQ0IynV1gAIG#fQLzuI58PE%5B4fkh{+{vj$|6G}L4O<}WVI>) zz_F1?GM=&86hbCLCUc18uzCLep>LFUsww@`8h7$sMxWpHsRx!P<5}Rf2^I;r4y8|b z$L?R_F-tfQ*F_%w^X@+SN3%$l;`_=w-|#OwyziH!=jpQIl9IGGu4_Xb%iJq}EL!V> zt=~dQt<5qhW?K_iMSIl(TV=JitYPrLS3_M#?3L@&!K-PK)pQkLM6QgmB~FOvh8j># zor-v|TY0NLY73mnz6P2zn2RXUYPr&K}+$01H&oG@Wp6@0&mFyv%XG^fn~Lz6mediOq12XYc6)cRjVHaZ6U6A+HcXJ}*P>A*Kl7U^F1%RIyzd z>`B&m_!_T#q%BTz?1>yEA(SLG6Q5GA`wKJe_NmVd${c&t_xl#|drMu-YGk%(v6z9+ zL{GLI`Jc;`6hUQ!G>bnxL{ zgs+TQDja5??R@v8y*porB2ErOs8VsgH)45sV?s#&FQvpN?0t@UhCu-^aum(yVDr6l zG#`Y!)`p~*C4cikajDa6k%NJFNttVOC_KNREZXZI9S(bZc7$hOSWd)%-;h<) zGsQ7?^uBZIx2Kxl%4Ac8iv>=|cV$dmpcy)cxl|_Q9~=75H~3JJ9B%RR7zGnV*NyM& zOS#NEp)8s<4DtuW5zZP%4EcDt8G)Et#agLtWO==EzHMbp=?fo8UujoxQqQKt0dVeW z%(wog(1|lv(NOz?K3~Jm_mz6a1APO5UhyHNaW@=43E~hgwDf0EvM}v?D<4*=SEF-W`SnvN^qK z`J^CafbtU2b;^m5={a9KpROXBW0nM0k6G{=sL0PuN zQ;%<7>v7rNZaTerZ37Z8MP`2}vVF@2?&X`mH9Z|ObhuM#8P!u7&E(^aGlMIXP>0L9 zVdK(xEQL8p2`Gq;An@a3QM1`JfBwAM8iu(%G(kRR$0#g}+Q|3lP=rxkT_%&!5XwjF zAjpp$%6Dc{V7FT-rH0NHH`Xp$UZLyP&|jxEG3PMlXn8Hxn$4#6zVrN&%K0yS;k!1I zl`<;v!ikP6_TuH5mNqD@v@0Lq@wd;u@zVM3E0x}|hVpqY9NxWS&8=>eeFPcz-utkw zU&DZxL|Kr8Lx*3xQNV2OG}^)(cTrI=&!;n)jGCpQSmskAuFs9>t9e9)+~EQx5N!0U zJ$j-i*8abWBtbZFF{EY(ZFkCoysNL_u2qYdRD?lmga(tObqw!7vmyXg*JTWET~aGZ z(n`0G47|rLF;M%lD~cE8fJSIF(@f<=`WAxc>$qZY#Gi~&L9Fz1F~ zMk^=FU8AwdT0cn^@^y04$O553NH%kA9~DI`0FG;#It%V0f3bS(>~ADNp0a`c^7MnahLsTdR2n8o9&^Zq0;|3hZV>&1Q zKpB+z%+59MS`}_eIfoI;@mDZ?&SF37aE zGef|*X}t1{(iIg8+d)%kA`1v7YWJqtS#;R@o3EHWA&cSwP|f*INeD1H-*k%0D--FX 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 e_x~FJ0RR8k26JI57)w0>0000", + 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().StringVarP(&opts.iconPath, "icon", "o", "", "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 @@ -44,15 +56,15 @@ func run(out io.Writer, path, iconPath string, noIcon bool) error { if err != nil { return err } - if iconPath != "" { + if opts.iconPath != "" { if info.Icon == nil { return errors.New("the app has no extractable icon") } - if err := os.WriteFile(iconPath, info.Icon.PNG, 0o644); err != nil { + if err := os.WriteFile(opts.iconPath, info.Icon.PNG, 0o644); err != nil { return err } } - if noIcon && info.Icon != nil { + if opts.noIcon && info.Icon != nil { info.Icon.PNG = nil } printJSON(out, info) diff --git a/cmd/appmeta/main_test.go b/cmd/appmeta/main_test.go index 4d89a8b..ac2ce5a 100644 --- a/cmd/appmeta/main_test.go +++ b/cmd/appmeta/main_test.go @@ -4,28 +4,47 @@ import ( "archive/zip" "bytes" "encoding/json" + "errors" "os" "path/filepath" "testing" + + "github.com/mobile-next/appmeta" ) -func TestTheCLIPrintsAnErrorForAFileThatIsNotAnApp(t *testing.T) { +func TestAFileThatIsNotAnAppIsReportedAsUnsupported(t *testing.T) { path := filepath.Join(t.TempDir(), "notes.zip") writeZipWithOneFile(t, path, "notes.txt") - var out bytes.Buffer - err := run(&out, path, "", false) - if err == nil { - t.Fatal("want an error") + err := runCommand(t, path) + if !errors.Is(err, appmeta.ErrUnsupportedFormat) { + t.Fatalf("got %v, want ErrUnsupportedFormat", err) } } -func TestTheCLIFailsForAMissingFile(t *testing.T) { - var out bytes.Buffer - if err := run(&out, filepath.Join(t.TempDir(), "missing.apk"), "", false); err == nil { +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 printJSON(&out, map[string]string{"error": "boom"}) @@ -35,6 +54,16 @@ func TestErrorsArePrintedAsJSON(t *testing.T) { } } +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 diff --git a/docs/decisions.md b/docs/decisions.md index cf8e408..854be9b 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -11,6 +11,7 @@ | 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 diff --git a/go.mod b/go.mod index 3a1f23b..ee219d9 100644 --- a/go.mod +++ b/go.mod @@ -6,8 +6,13 @@ toolchain go1.26.6 require ( github.com/santhosh-tekuri/jsonschema/v6 v6.0.3 + github.com/spf13/cobra v1.10.2 golang.org/x/image v0.46.0 howett.net/plist v1.0.1 ) -require golang.org/x/text v0.42.0 // indirect +require ( + github.com/inconshreveable/mousetrap v1.1.0 // indirect + github.com/spf13/pflag v1.0.9 // indirect + golang.org/x/text v0.42.0 // indirect +) diff --git a/go.sum b/go.sum index 377b1ea..de8b36d 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,17 @@ +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= From ac3bee9d8a3ccf64a670e89b6c1255a9d182f477 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:11:03 +0200 Subject: [PATCH 11/17] fix: drop the CLI short flag --- README.md | 2 +- cmd/appmeta/main.go | 2 +- cmd/appmeta/main_test.go | 12 ++++++++++++ go.mod | 2 +- 4 files changed, 15 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index 63b89d7..9f3e5fb 100644 --- a/README.md +++ b/README.md @@ -30,7 +30,7 @@ defaults. The JSON shape is documented in 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 -o icon.png app.apk # also write the icon (--icon) +appmeta --icon icon.png app.apk # also write the icon ``` On failure it prints `{"error": "..."}` and exits non-zero. diff --git a/cmd/appmeta/main.go b/cmd/appmeta/main.go index 732e9c3..3a748cb 100644 --- a/cmd/appmeta/main.go +++ b/cmd/appmeta/main.go @@ -37,7 +37,7 @@ func newRootCommand(out io.Writer) *cobra.Command { return run(out, args[0], opts) }, } - cmd.Flags().StringVarP(&opts.iconPath, "icon", "o", "", "also write the icon PNG to this file") + 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 } diff --git a/cmd/appmeta/main_test.go b/cmd/appmeta/main_test.go index ac2ce5a..133d4fc 100644 --- a/cmd/appmeta/main_test.go +++ b/cmd/appmeta/main_test.go @@ -9,6 +9,8 @@ import ( "path/filepath" "testing" + "github.com/spf13/pflag" + "github.com/mobile-next/appmeta" ) @@ -78,3 +80,13 @@ func writeZipWithOneFile(t *testing.T, path, name string) { 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/go.mod b/go.mod index ee219d9..b4aa780 100644 --- a/go.mod +++ b/go.mod @@ -7,12 +7,12 @@ 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 - github.com/spf13/pflag v1.0.9 // indirect golang.org/x/text v0.42.0 // indirect ) From 251f131b6c881d9bc499d0a26981266445cbbe6a Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:11:59 +0200 Subject: [PATCH 12/17] feat: add ParseContext --- README.md | 3 ++- appmeta.go | 64 +++++++++++++++++++++++++++++++++++--------- context_test.go | 68 +++++++++++++++++++++++++++++++++++++++++++++++ docs/decisions.md | 6 +++-- 4 files changed, 126 insertions(+), 15 deletions(-) create mode 100644 context_test.go diff --git a/README.md b/README.md index 9f3e5fb..54b6431 100644 --- a/README.md +++ b/README.md @@ -19,7 +19,8 @@ info, err := appmeta.Parse(f, st.Size()) // info.BundleID, info.Version, info.Icon.PNG, ... ``` -`Parse(r io.ReaderAt, size int64, opts ...Option) (*Info, error)`. Tighten +`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). diff --git a/appmeta.go b/appmeta.go index 4248df8..6c50c91 100644 --- a/appmeta.go +++ b/appmeta.go @@ -5,12 +5,12 @@ // range requests or S3 ranged GETs. Input is treated as hostile: every read is // bounded by Limits and Parse never lets a panic escape. // -// Parse takes no context. Callers that need a deadline should pass a ReaderAt -// that fails once their context is done; the limits bound the CPU work done -// between reads. +// ParseContext stops when its context is done; the limits bound the CPU work +// done between reads. package appmeta import ( + "context" "errors" "fmt" "io" @@ -69,20 +69,60 @@ var ( ) // 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 *Info, err error) { - // The host must survive any input, so a parser bug becomes an error. - defer func() { - if p := recover(); p != nil { - info = nil - err = fmt.Errorf("appmeta: internal error: %v", p) - } - }() +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) } - return parse(r, size, cfg.limits) + + 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: + 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. 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 index 854be9b..d3c6403 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -48,8 +48,10 @@ fat slices. `Parse` recovers panics into errors. The fuzz targets call the inner parsers, so a panic still fails fuzzing. -There is no context/deadline parameter (the PRD signature has none). A host -that needs a deadline passes a ReaderAt that fails once its context is done. +`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 From 1376294454d5bfa266b1fe433fe3ba4519335cad Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:14:12 +0200 Subject: [PATCH 13/17] feat: report debug or release APK signing --- apk.go | 3 + apksign.go | 213 +++++++++++++++++++++++++ apksign_test.go | 63 ++++++++ appmeta.go | 8 +- archive.go | 55 ++++--- docs/decisions.md | 1 + fixtures_apk_test.go | 12 +- fixtures_apksign_test.go | 128 +++++++++++++++ fuzz_test.go | 11 ++ schema/appmeta.schema.json | 4 +- testdata/golden/apk-acme-shop.json | 6 +- testdata/golden/apk-adaptive-icon.json | 6 +- 12 files changed, 481 insertions(+), 29 deletions(-) create mode 100644 apksign.go create mode 100644 apksign_test.go create mode 100644 fixtures_apksign_test.go diff --git a/apk.go b/apk.go index 93e1ab9..b96213f 100644 --- a/apk.go +++ b/apk.go @@ -85,6 +85,9 @@ func parseAPK(a *archive) (*Info, error) { info.Name = m.packageName } extractAPKIcon(res, m.icon, info) + if info.Signing, err = apkSigning(a); err != nil { + info.Warnings = append(info.Warnings, fmt.Sprintf("signing unknown: %v", err)) + } return info, nil } diff --git a/apksign.go b/apksign.go new file mode 100644 index 0000000..950beab --- /dev/null +++ b/apksign.go @@ -0,0 +1,213 @@ +package appmeta + +import ( + "bytes" + "crypto/x509" + "encoding/asn1" + "encoding/binary" + "errors" + "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 = errors.New("appmeta: malformed apk signature") + // 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 "debug" + } + return "release" +} + +// 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 := readAt(a.r, int64(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 := readAt(a.r, int64(cdOffset-size-signingBlockLenLen), int64(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 index 6c50c91..785220d 100644 --- a/appmeta.go +++ b/appmeta.go @@ -43,9 +43,13 @@ type Info struct { Warnings []string `json:"warnings"` } -// Signing describes the provisioning profile an iOS app was signed with. +// 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 { - Type string `json:"type"` // "development", "ad-hoc", "enterprise" or "app-store" + // "development", "ad-hoc", "enterprise" or "app-store" on iOS; + // "debug" or "release" on Android. + Type string `json:"type"` TeamID string `json:"teamId"` ExpiresAt *time.Time `json:"expiresAt"` } diff --git a/archive.go b/archive.go index d7d4387..44bc7ed 100644 --- a/archive.go +++ b/archive.go @@ -16,6 +16,7 @@ const ( zip64LocatorLen = 20 zip64EOCDLen = 56 zip16BitEntryCountMax = 0xFFFF + zip32BitOffsetMax = 0xFFFFFFFF ) var ( @@ -27,19 +28,21 @@ var ( // 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 io.ReaderAt + directory zipDirectory files map[string]*zip.File limits Limits totalRead int64 } func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { - count, err := declaredEntryCount(r, size) + dir, err := readZipDirectory(r, size) if err != nil { return nil, err } // archive/zip allocates per declared entry, so check the count first. - if count > uint64(limits.MaxEntries) { - return nil, fmt.Errorf("%w: archive declares %d entries, max %d", ErrLimitExceeded, count, limits.MaxEntries) + 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) @@ -55,52 +58,64 @@ func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { files[f.Name] = f } } - return &archive{files: files, limits: limits}, nil + return &archive{r: r, directory: dir, files: files, limits: limits}, nil } -// declaredEntryCount reads the entry count from the end of central directory -// record, following the zip64 locator when the 16-bit field overflows. -func declaredEntryCount(r io.ReaderAt, size int64) (uint64, error) { +// 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. +func readZipDirectory(r io.ReaderAt, size int64) (zipDirectory, error) { tailLen := min(size, eocdLen+maxZipCommentLen) if tailLen < eocdLen { - return 0, ErrUnsupportedFormat + return zipDirectory{}, ErrUnsupportedFormat } tail, err := readAt(r, size-tailLen, tailLen) if err != nil { - return 0, err + return zipDirectory{}, err } i := bytes.LastIndex(tail, eocdSignature) if i < 0 || len(tail)-i < eocdLen { - return 0, ErrUnsupportedFormat + return zipDirectory{}, ErrUnsupportedFormat + } + dir := zipDirectory{ + entries: uint64(binary.LittleEndian.Uint16(tail[i+10:])), + offset: uint64(binary.LittleEndian.Uint32(tail[i+16:])), } - count := uint64(binary.LittleEndian.Uint16(tail[i+10:])) - if count != zip16BitEntryCountMax { - return count, nil + if dir.entries != zip16BitEntryCountMax && dir.offset != zip32BitOffsetMax { + return dir, nil } locatorOffset := size - tailLen + int64(i) - zip64LocatorLen if locatorOffset < 0 { - return count, nil + return dir, nil } locator, err := readAt(r, locatorOffset, zip64LocatorLen) if err != nil { - return 0, err + return zipDirectory{}, err } if !bytes.Equal(locator[:4], zip64LocatorSignature) { - return count, nil + return dir, nil } recordOffset := binary.LittleEndian.Uint64(locator[8:]) if recordOffset > uint64(size-zip64EOCDLen) { - return 0, fmt.Errorf("%w: zip64 record out of bounds", ErrUnsupportedFormat) + return zipDirectory{}, fmt.Errorf("%w: zip64 record out of bounds", ErrUnsupportedFormat) } record, err := readAt(r, int64(recordOffset), zip64EOCDLen) if err != nil { - return 0, err + return zipDirectory{}, err } if !bytes.Equal(record[:4], zip64EOCDSignature) { - return 0, fmt.Errorf("%w: bad zip64 record", ErrUnsupportedFormat) + return zipDirectory{}, fmt.Errorf("%w: bad zip64 record", ErrUnsupportedFormat) } - return binary.LittleEndian.Uint64(record[32:]), nil + 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) { diff --git a/docs/decisions.md b/docs/decisions.md index d3c6403..4375418 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -7,6 +7,7 @@ | 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.** | diff --git a/fixtures_apk_test.go b/fixtures_apk_test.go index b763709..f714a91 100644 --- a/fixtures_apk_test.go +++ b/fixtures_apk_test.go @@ -79,9 +79,14 @@ func acmeShopManifest() xmlNode { ) } -// buildAcmeShopAPK is a typical release APK: resource label, icons at two -// densities plus an adaptive icon, permissions and two ABIs. +// 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)}, @@ -94,7 +99,7 @@ func buildAcmeShopAPK(t testing.TB) []byte { } // buildAdaptiveIconAPK has only an adaptive icon, as apps with minSdk 26 -// often do. +// 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{ @@ -112,6 +117,7 @@ func buildAdaptiveIconAPK(t testing.TB) []byte { 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))}, ) } 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/fuzz_test.go b/fuzz_test.go index b955983..54a8a5e 100644 --- a/fuzz_test.go +++ b/fuzz_test.go @@ -71,6 +71,7 @@ func FuzzMachO(f *testing.F) { 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)) @@ -79,3 +80,13 @@ func FuzzParse(f *testing.F) { _, _ = 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/schema/appmeta.schema.json b/schema/appmeta.schema.json index c0253f3..d9c4934 100644 --- a/schema/appmeta.schema.json +++ b/schema/appmeta.schema.json @@ -64,7 +64,7 @@ "items": { "type": "string" } }, "signing": { - "description": "iOS provisioning profile summary; null for Android and for unsigned or simulator builds.", + "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" }, { @@ -72,7 +72,7 @@ "additionalProperties": false, "required": ["type", "teamId", "expiresAt"], "properties": { - "type": { "enum": ["development", "ad-hoc", "enterprise", "app-store"] }, + "type": { "enum": ["development", "ad-hoc", "enterprise", "app-store", "debug", "release"] }, "teamId": { "type": "string" }, "expiresAt": { "oneOf": [{ "type": "null" }, { "type": "string", "format": "date-time" }] diff --git a/testdata/golden/apk-acme-shop.json b/testdata/golden/apk-acme-shop.json index 8b2af78..2a327a5 100644 --- a/testdata/golden/apk-acme-shop.json +++ b/testdata/golden/apk-acme-shop.json @@ -22,7 +22,11 @@ "android.permission.CAMERA", "android.permission.INTERNET" ], - "signing": null, + "signing": { + "type": "release", + "teamId": "", + "expiresAt": null + }, "icon": { "contentType": "image/png", "width": 192, diff --git a/testdata/golden/apk-adaptive-icon.json b/testdata/golden/apk-adaptive-icon.json index 6f2cdf9..eb45be1 100644 --- a/testdata/golden/apk-adaptive-icon.json +++ b/testdata/golden/apk-adaptive-icon.json @@ -16,7 +16,11 @@ ], "architectures": [], "permissions": [], - "signing": null, + "signing": { + "type": "debug", + "teamId": "", + "expiresAt": null + }, "icon": { "contentType": "image/png", "width": 108, From 2d8c3fc1e536f4465136ee419c77b25056fffb19 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:14:20 +0200 Subject: [PATCH 14/17] chore: make builds the binary by default --- Makefile | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Makefile b/Makefile index 2b9d6b5..c91fe35 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,8 @@ .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 @@ -10,6 +13,5 @@ fmt: test: go test -race ./... -vulncheck: - go build -o appmeta ./cmd/appmeta +vulncheck: all $(shell go env GOPATH)/bin/govulncheck -mode=binary ./appmeta From 1f99b0123844f40355dd36762b6d056e133bc17b Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:15:38 +0200 Subject: [PATCH 15/17] ci: fuzz the APK signing parser --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d17846d..770b6a9 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -56,7 +56,7 @@ jobs: strategy: fail-fast: false matrix: - target: [FuzzAXML, FuzzResourceTable, FuzzPlist, FuzzIcon, FuzzMachO, FuzzParse] + target: [FuzzAXML, FuzzAPKSigning, FuzzResourceTable, FuzzPlist, FuzzIcon, FuzzMachO, FuzzParse] steps: - uses: actions/checkout@v5 with: From 012826ebdb86feda887caee05104e3d9c42dd1bd Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:33:19 +0200 Subject: [PATCH 16/17] fix: harden parsing against read failures and hostile archives Read failures and cancelled contexts now fail the parse instead of becoming warnings. MaxEntries is checked against the entries actually read, the zip64 lookup honours the directory size marker, and the APK signing block counts towards MaxTotalSize. Permission deduplication is linear, and brackets inside XML plist strings no longer count as nesting. Adds ErrMalformed and constants for the format, platform and signing values. The CLI reports a failed write of its output. --- apk.go | 185 ++++++++++++++++++++-------------- apksign.go | 11 +- appmeta.go | 46 ++++++++- archive.go | 73 ++++++++++++-- cgbi.go | 2 +- cmd/appmeta/main.go | 10 +- cmd/appmeta/main_test.go | 19 +++- hardening_test.go | 213 +++++++++++++++++++++++++++++++++++++++ ipa.go | 92 +++++++++-------- mobileprovision.go | 8 +- plist.go | 24 ++++- resource_chunk.go | 3 +- 12 files changed, 536 insertions(+), 150 deletions(-) create mode 100644 hardening_test.go diff --git a/apk.go b/apk.go index b96213f..b9c1ef9 100644 --- a/apk.go +++ b/apk.go @@ -51,6 +51,8 @@ type apkManifest struct { debuggable bool permissions []string features []apkFeature + // seenPermissions keeps the deduplication of permissions linear. + seenPermissions map[string]struct{} } func parseAPK(a *archive) (*Info, error) { @@ -67,8 +69,8 @@ func parseAPK(a *archive) (*Info, error) { } info := &Info{ - Format: "apk", - Platform: "android", + Format: FormatAPK, + Platform: PlatformAndroid, BundleID: m.packageName, BuildNumber: m.versionCode, MinOSVersion: androidRelease(m.minSDK), @@ -79,12 +81,16 @@ func parseAPK(a *archive) (*Info, error) { Permissions: m.permissions, } res := &apkResources{archive: a} - info.Version = res.text(m.versionName, "android:versionName", info) - info.Name = res.text(m.label, "android:label", info) + 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 } - extractAPKIcon(res, m.icon, info) + 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)) } @@ -103,113 +109,146 @@ type apkResources struct { func (r *apkResources) get() (*resourceTable, error) { if !r.loaded { r.loaded = true - data, err := r.archive.read(androidResourcesPath) - if err == nil { - r.table, err = parseResourceTable(data) - } - if err != nil { - r.err = fmt.Errorf("%s: %w", androidResourcesPath, err) - } + 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 -// warnings: the rest of the metadata is still useful. -func (r *apkResources) text(attr xmlAttr, field string, info *Info) string { +// 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() + return attr.text(), "" } table, err := r.get() if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("%s %s not resolved: %v", field, formatResID(attr.data), err)) - return "" + return "", fmt.Sprintf("%s %s not resolved: %v", field, formatResID(attr.data), err) } s, ok := table.resolveText(attr.data) if !ok { - info.Warnings = append(info.Warnings, fmt.Sprintf("%s %s not found in %s", field, formatResID(attr.data), androidResourcesPath)) + return "", fmt.Sprintf("%s %s not found in %s", field, formatResID(attr.data), androidResourcesPath) } - return s + return s, "" } func parseManifest(data []byte, maxDepth int) (*apkManifest, error) { - m := &apkManifest{} - err := parseAXML(data, maxDepth, func(path []string, attrs []xmlAttr) { - switch { - case pathIs(path, "manifest"): - 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") - case pathIs(path, "manifest", "uses-sdk"): - m.minSDK, _ = findAttr(attrs, attrMinSDKVersion, "minSdkVersion") - m.targetSDK, _ = findAttr(attrs, attrTargetSDKVersion, "targetSdkVersion") - case pathIs(path, "manifest", "application"): - 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 - } - case pathIs(path, "manifest", "uses-permission"), - pathIs(path, "manifest", "uses-permission-sdk-23"), - pathIs(path, "manifest", "uses-permission-sdk-m"): - if a, ok := findAttr(attrs, attrName, "name"); ok && a.text() != "" && !slices.Contains(m.permissions, a.text()) { - m.permissions = append(m.permissions, a.text()) - } - case pathIs(path, "manifest", "uses-feature"): - 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) - } - }) - if err != nil { + m := &apkManifest{seenPermissions: map[string]struct{}{}} + if err := parseAXML(data, maxDepth, m.readElement); err != nil { return nil, err } return m, nil } -func extractAPKIcon(res *apkResources, attr xmlAttr, info *Info) { - if !attr.isReference() { +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 { - info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %v", err)) - return + return nil, fmt.Sprintf("icon not extracted: %v", err) } files := table.resolveFiles(attr.data) icon, err := firstDecodableRaster(res.archive, files) if icon != nil { - info.Icon = icon - return + 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(res.archive, f.path) + foreground, ok := adaptiveIconForeground(a, f.path) if !ok { continue } - if icon, _ := firstDecodableRaster(res.archive, table.resolveFiles(foreground)); icon != nil { - info.Icon = icon - info.Warnings = append(info.Warnings, "adaptive icon rendered from foreground layer only") - return + if icon, _ := firstDecodableRaster(a, table.resolveFiles(foreground)); icon != nil { + return icon } } - if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %v", err)) - return - } - info.Warnings = append(info.Warnings, "icon not extracted: no raster image found (vector drawables are not rendered)") + return nil } // firstDecodableRaster tries raster files from the highest density down. diff --git a/apksign.go b/apksign.go index 950beab..36eef24 100644 --- a/apksign.go +++ b/apksign.go @@ -5,7 +5,6 @@ import ( "crypto/x509" "encoding/asn1" "encoding/binary" - "errors" "fmt" "slices" "strings" @@ -26,7 +25,7 @@ const ( var ( signingBlockMagic = []byte("APK Sig Block 42") - errMalformedSigning = errors.New("appmeta: malformed apk signature") + 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} ) @@ -55,9 +54,9 @@ func apkSigning(a *archive) (*Signing, error) { 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 "debug" + return SigningDebug } - return "release" + return SigningRelease } // signingBlockCertificate finds the APK Signing Block that sits right before @@ -67,7 +66,7 @@ func signingBlockCertificate(a *archive) ([]byte, error) { if cdOffset < signingBlockFooterLen+signingBlockLenLen { return nil, nil } - footer, err := readAt(a.r, int64(cdOffset-signingBlockFooterLen), signingBlockFooterLen) + footer, err := a.readRange(cdOffset-signingBlockFooterLen, signingBlockFooterLen) if err != nil { return nil, err } @@ -79,7 +78,7 @@ func signingBlockCertificate(a *archive) ([]byte, error) { 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 := readAt(a.r, int64(cdOffset-size-signingBlockLenLen), int64(size+signingBlockLenLen)) + block, err := a.readRange(cdOffset-size-signingBlockLenLen, size+signingBlockLenLen) if err != nil { return nil, err } diff --git a/appmeta.go b/appmeta.go index 785220d..5e49474 100644 --- a/appmeta.go +++ b/appmeta.go @@ -21,12 +21,30 @@ import ( // 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"` // "apk" or "ipa" - Platform string `json:"platform"` // "android" or "ios" + 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"` @@ -47,8 +65,7 @@ type Info struct { // provisioning profile; for Android it tells debug-key builds from release // builds, and TeamID and ExpiresAt are empty. type Signing struct { - // "development", "ad-hoc", "enterprise" or "app-store" on iOS; - // "debug" or "release" on Android. + // One of the Signing constants. Type string `json:"type"` TeamID string `json:"teamId"` ExpiresAt *time.Time `json:"expiresAt"` @@ -70,6 +87,9 @@ var ( 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. @@ -109,6 +129,10 @@ func ParseContext(ctx context.Context, r io.ReaderAt, size int64, opts ...Option 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()) @@ -145,6 +169,11 @@ func parse(r io.ReaderAt, size int64, limits Limits) (*Info, error) { 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 } @@ -153,6 +182,15 @@ func parse(r io.ReaderAt, size int64, limits Limits) (*Info, error) { 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) { diff --git a/archive.go b/archive.go index 44bc7ed..d8c5536 100644 --- a/archive.go +++ b/archive.go @@ -16,6 +16,7 @@ const ( zip64LocatorLen = 20 zip64EOCDLen = 56 zip16BitEntryCountMax = 0xFFFF + zip32BitSizeMax = 0xFFFFFFFF zip32BitOffsetMax = 0xFFFFFFFF ) @@ -28,14 +29,48 @@ var ( // 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 io.ReaderAt + r *failureRecordingReader + size int64 directory zipDirectory files map[string]*zip.File limits Limits totalRead int64 } -func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { +// 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 @@ -50,6 +85,11 @@ func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { 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 { @@ -58,7 +98,7 @@ func openArchive(r io.ReaderAt, size int64, limits Limits) (*archive, error) { files[f.Name] = f } } - return &archive{r: r, directory: dir, files: files, limits: limits}, nil + return &archive{r: r, size: size, directory: dir, files: files, limits: limits}, nil } // zipDirectory is what the end of central directory record declares. @@ -68,7 +108,7 @@ type zipDirectory struct { } // readZipDirectory reads the end of central directory record, following the -// zip64 locator when a 16- or 32-bit field overflows. +// 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 { @@ -86,11 +126,17 @@ func readZipDirectory(r io.ReaderAt, size int64) (zipDirectory, error) { entries: uint64(binary.LittleEndian.Uint16(tail[i+10:])), offset: uint64(binary.LittleEndian.Uint32(tail[i+16:])), } - if dir.entries != zip16BitEntryCountMax && dir.offset != zip32BitOffsetMax { + 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) +} - locatorOffset := size - tailLen + int64(i) - zip64LocatorLen +// 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 } @@ -102,7 +148,7 @@ func readZipDirectory(r io.ReaderAt, size int64) (zipDirectory, error) { return dir, nil } recordOffset := binary.LittleEndian.Uint64(locator[8:]) - if recordOffset > uint64(size-zip64EOCDLen) { + 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) @@ -130,6 +176,19 @@ func readAt(r io.ReaderAt, off, n int64) ([]byte, error) { 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 diff --git a/cgbi.go b/cgbi.go index 04954d1..34dd9f4 100644 --- a/cgbi.go +++ b/cgbi.go @@ -22,7 +22,7 @@ const ( pngColorRGBA = 6 ) -var errMalformedCgBI = errors.New("appmeta: malformed CgBI png") +var errMalformedCgBI = fmt.Errorf("%w: CgBI png", ErrMalformed) func isCgBI(data []byte) bool { rest := data[len(pngSignature):] diff --git a/cmd/appmeta/main.go b/cmd/appmeta/main.go index 3a748cb..d328068 100644 --- a/cmd/appmeta/main.go +++ b/cmd/appmeta/main.go @@ -20,7 +20,8 @@ type options struct { func main() { cmd := newRootCommand(os.Stdout) if err := cmd.Execute(); err != nil { - printJSON(os.Stdout, map[string]string{"error": err.Error()}) + // Nothing is left to report to if stdout itself is what failed. + _ = printJSON(os.Stdout, map[string]string{"error": err.Error()}) os.Exit(1) } } @@ -67,12 +68,11 @@ func run(out io.Writer, path string, opts options) error { if opts.noIcon && info.Icon != nil { info.Icon.PNG = nil } - printJSON(out, info) - return nil + return printJSON(out, info) } -func printJSON(out io.Writer, v any) { +func printJSON(out io.Writer, v any) error { enc := json.NewEncoder(out) enc.SetIndent("", " ") - _ = enc.Encode(v) + return enc.Encode(v) } diff --git a/cmd/appmeta/main_test.go b/cmd/appmeta/main_test.go index 133d4fc..ee1a827 100644 --- a/cmd/appmeta/main_test.go +++ b/cmd/appmeta/main_test.go @@ -49,13 +49,30 @@ func TestExactlyOnePathIsRequired(t *testing.T) { func TestErrorsArePrintedAsJSON(t *testing.T) { var out bytes.Buffer - printJSON(&out, map[string]string{"error": "boom"}) + 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 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/ipa.go b/ipa.go index d581c8c..dfb48a2 100644 --- a/ipa.go +++ b/ipa.go @@ -50,8 +50,8 @@ func parseIPA(a *archive) (*Info, error) { } info := &Info{ - Format: "ipa", - Platform: "ios", + Format: FormatIPA, + Platform: PlatformIOS, BundleID: bundleID, Name: firstNonEmpty(plistString(plist, "CFBundleDisplayName"), plistString(plist, "CFBundleName"), bundleID), Version: plistString(plist, "CFBundleShortVersionString"), @@ -61,49 +61,51 @@ func parseIPA(a *archive) (*Info, error) { IsSimulator: strings.Contains(strings.ToLower(plistString(plist, "DTPlatformName")), "simulator"), DeviceFamilies: deviceFamilies(plist), } - sniffExecutable(a, bundle, plistString(plist, "CFBundleExecutable"), info) - readProvisioningProfile(a, bundle, info) - extractIPAIcon(a, bundle, plist, info) + 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 } -// sniffExecutable prefers the Mach-O platform over DTPlatformName, which is -// only what Xcode wrote into Info.plist. -func sniffExecutable(a *archive, bundle, executable string, info *Info) { +func sniffExecutable(a *archive, bundle, executable string) (machOSummary, string) { if executable == "" { - info.Warnings = append(info.Warnings, "architectures unknown: Info.plist has no CFBundleExecutable") - return + return machOSummary{}, "architectures unknown: Info.plist has no CFBundleExecutable" } data, err := a.readPrefix(bundle+executable, machOPrefixLen) if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("architectures unknown: %v", err)) - return + return machOSummary{}, fmt.Sprintf("architectures unknown: %v", err) } summary, err := sniffMachO(data) if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("architectures unknown: %s: %v", executable, err)) - return - } - info.Architectures = summary.architectures - if summary.hasPlatform { - info.IsSimulator = summary.isSimulator + return machOSummary{}, fmt.Sprintf("architectures unknown: %s: %v", executable, err) } + return summary, "" } -// readProvisioningProfile leaves signing null when there is no profile, as +// readProvisioningProfile returns no signing when there is no profile, as // for simulator builds and App Store downloads. -func readProvisioningProfile(a *archive, bundle string, info *Info) { +func readProvisioningProfile(a *archive, bundle string) (signing *Signing, isDebuggable bool, warning string) { path := bundle + "embedded.mobileprovision" if !a.has(path) { - return + return nil, false, "" } data, err := a.read(path) if err == nil { - info.Signing, info.IsDebuggable, err = parseProvisioningProfile(data, a.limits.MaxDepth) + signing, isDebuggable, err = parseProvisioningProfile(data, a.limits.MaxDepth) } if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("signing unknown: embedded.mobileprovision: %v", err)) + return nil, false, fmt.Sprintf("signing unknown: embedded.mobileprovision: %v", err) } + return signing, isDebuggable, "" } func firstNonEmpty(values ...string) string { @@ -129,35 +131,39 @@ func deviceFamilies(plist map[string]any) []string { return families } -func extractIPAIcon(a *archive, bundle string, plist map[string]any, info *Info) { - var best string +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 iconCandidates(a, bundle, iconBaseNames(plist)) { + for _, path := range candidates { data, err := a.read(path) if err != nil { continue } w, h, err := imageSize(data) if err == nil && w*h > bestArea { - best, bestArea = path, w*h + bestPath, bestData, bestArea = path, data, w*h } } - if best == "" { - primary := plistDict(plistDict(plist, "CFBundleIcons"), "CFBundlePrimaryIcon") - if a.has(bundle+"Assets.car") || plistString(primary, "CFBundleIconName") != "" { - info.Warnings = append(info.Warnings, "icon not extracted: it is only in Assets.car") - return - } - info.Warnings = append(info.Warnings, "icon not extracted: no icon file found") - return - } - data, err := a.read(best) - if err == nil { - info.Icon, err = encodeIcon(data, a.limits.MaxIconPixels) - } - if err != nil { - info.Warnings = append(info.Warnings, fmt.Sprintf("icon not extracted: %s: %v", strings.TrimPrefix(best, bundle), err)) - } + return bestPath, bestData } // iconBaseNames lists icon names from every place Info.plist declares them. diff --git a/mobileprovision.go b/mobileprovision.go index 816655b..197cba3 100644 --- a/mobileprovision.go +++ b/mobileprovision.go @@ -50,11 +50,11 @@ func parseProvisioningProfile(data []byte, maxDepth int) (*Signing, bool, error) func profileType(profile map[string]any, getTaskAllow bool) string { switch { case plistBool(profile, "ProvisionsAllDevices"): - return "enterprise" + return SigningEnterprise case len(plistStrings(profile, "ProvisionedDevices")) > 0 && getTaskAllow: - return "development" + return SigningDevelopment case len(plistStrings(profile, "ProvisionedDevices")) > 0: - return "ad-hoc" + return SigningAdHoc } - return "app-store" + return SigningAppStore } diff --git a/plist.go b/plist.go index 711068e..49afa9f 100644 --- a/plist.go +++ b/plist.go @@ -24,21 +24,21 @@ const ( var ( bplistMagic = []byte("bplist") - errMalformedPlist = errors.New("appmeta: malformed plist") + 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, if that fails, to its text parser, so both nestings -// are checked. +// 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 { + if err == nil && !isXMLPlist(data) { err = checkBracketDepth(data, maxDepth) } } @@ -52,6 +52,22 @@ func decodePlist(data []byte, maxDepth int) (map[string]any, error) { 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 diff --git a/resource_chunk.go b/resource_chunk.go index 9c10198..9c0470a 100644 --- a/resource_chunk.go +++ b/resource_chunk.go @@ -2,7 +2,6 @@ package appmeta import ( "encoding/binary" - "errors" "fmt" "unicode/utf16" ) @@ -31,7 +30,7 @@ const ( resValueTypeBool = 0x12 ) -var errMalformedResource = errors.New("appmeta: malformed android resource") +var errMalformedResource = fmt.Errorf("%w: android resource", ErrMalformed) var le = binary.LittleEndian From 94563d91da492cbe89fb0d8ad6dfff53e4531687 Mon Sep 17 00:00:00 2001 From: gmegidish Date: Tue, 29 Sep 2026 11:33:19 +0200 Subject: [PATCH 17/17] ci: run on every branch push and release on version tags A vMAJOR.MINOR.PATCH tag creates a GitHub release whose notes are the matching section of CHANGELOG.md. --- .github/workflows/ci.yml | 32 +++++++++++++++++++++++++++++++- CHANGELOG.md | 2 ++ 2 files changed, 33 insertions(+), 1 deletion(-) create mode 100644 CHANGELOG.md diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 770b6a9..e02aaca 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -3,7 +3,9 @@ name: ci on: push: branches: - - main + - "**" + tags: + - "v*.*.*" pull_request: permissions: @@ -66,3 +68,31 @@ jobs: 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/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