From 51d3504e9a0996e74e5e1b6cec4eecb2427827c0 Mon Sep 17 00:00:00 2001 From: PJ Date: Mon, 31 Aug 2026 10:21:45 +0530 Subject: [PATCH] feat(android): read the application id out of an apk parses the compiled AndroidManifest.xml rather than shelling out to aapt2, which lives in the versioned build-tools directory that hosts with only platform-tools never install. Claude-Session: https://claude.ai/code/session_012PVErdr3ZzyUASeVQDWsUc --- internal/android/apk.go | 228 ++++++++++++++++++++++++++++ internal/android/apk_test.go | 173 +++++++++++++++++++++ internal/android/testdata/folio.apk | Bin 0 -> 1759 bytes 3 files changed, 401 insertions(+) create mode 100644 internal/android/apk.go create mode 100644 internal/android/apk_test.go create mode 100644 internal/android/testdata/folio.apk diff --git a/internal/android/apk.go b/internal/android/apk.go new file mode 100644 index 0000000..b71d541 --- /dev/null +++ b/internal/android/apk.go @@ -0,0 +1,228 @@ +package android + +import ( + "archive/zip" + "encoding/binary" + "errors" + "fmt" + "io" + "unicode/utf16" +) + +const ( + manifestEntry = "AndroidManifest.xml" + + chunkStringPool = 0x0001 + chunkStartElement = 0x0102 + + stringPoolUTF8 = 1 << 8 +) + +var errStringPoolRange = errors.New("string pool entry runs past the chunk") + +// PackageName reads an APK's application id out of its compiled manifest, +// which carries the id the package manager will know the app by, suffixes and +// all. Parsing it here rather than shelling out to `aapt2 dump packagename` +// keeps the read working on the many hosts that install platform-tools for adb +// and never install the versioned build-tools directory aapt2 lives in. +func PackageName(apkPath string) (string, error) { + manifest, err := manifestFromAPK(apkPath) + if err != nil { + return "", err + } + name, err := manifestPackage(manifest) + if err != nil { + return "", fmt.Errorf("%s: %w", apkPath, err) + } + return name, nil +} + +func manifestFromAPK(apkPath string) ([]byte, error) { + archive, err := zip.OpenReader(apkPath) + if err != nil { + return nil, fmt.Errorf("open %s: %w", apkPath, err) + } + defer archive.Close() + for _, file := range archive.File { + if file.Name != manifestEntry { + continue + } + entry, err := file.Open() + if err != nil { + return nil, fmt.Errorf("open %s in %s: %w", manifestEntry, apkPath, err) + } + defer entry.Close() + manifest, err := io.ReadAll(entry) + if err != nil { + return nil, fmt.Errorf("read %s in %s: %w", manifestEntry, apkPath, err) + } + return manifest, nil + } + return nil, fmt.Errorf("%s holds no %s", apkPath, manifestEntry) +} + +// manifestPackage walks the compiled manifest's chunks for the +// element and reports its package attribute. The string pool always precedes +// the elements that index into it. +func manifestPackage(manifest []byte) (string, error) { + if len(manifest) < 8 { + return "", errors.New("truncated binary xml") + } + var pool []string + for offset := uint32(8); offset+8 <= uint32(len(manifest)); { + size := binary.LittleEndian.Uint32(manifest[offset+4:]) + if size < 8 || uint64(offset)+uint64(size) > uint64(len(manifest)) { + return "", errors.New("truncated chunk in binary xml") + } + chunk := manifest[offset : offset+size] + switch binary.LittleEndian.Uint16(chunk) { + case chunkStringPool: + var err error + if pool, err = poolStrings(chunk); err != nil { + return "", err + } + case chunkStartElement: + name, found, err := packageAttribute(chunk, pool) + if err != nil || found { + return name, err + } + } + offset += size + } + return "", errors.New("no element in binary xml") +} + +// packageAttribute reports the package attribute of a start-element chunk, and +// whether that chunk was the element at all. +func packageAttribute(chunk []byte, pool []string) (string, bool, error) { + // The element's own fields start after the chunk header, line number and + // comment index, and the attribute offsets are relative to there. + const element = 16 + if len(chunk) < element+20 { + return "", false, errors.New("truncated start-element chunk") + } + if poolString(pool, binary.LittleEndian.Uint32(chunk[element+4:])) != "manifest" { + return "", false, nil + } + start := uint64(binary.LittleEndian.Uint16(chunk[element+8:])) + stride := uint64(binary.LittleEndian.Uint16(chunk[element+10:])) + count := uint64(binary.LittleEndian.Uint16(chunk[element+12:])) + if stride < 20 { + return "", false, fmt.Errorf(" attribute stride is %d bytes, want at least 20", stride) + } + for index := uint64(0); index < count; index++ { + at := element + start + index*stride + if at+20 > uint64(len(chunk)) { + return "", false, errors.New(" attribute runs past the chunk") + } + if poolString(pool, binary.LittleEndian.Uint32(chunk[at+4:])) != "package" { + continue + } + name := poolString(pool, binary.LittleEndian.Uint32(chunk[at+8:])) + if name == "" { + // aapt2 leaves the raw value unset and keeps the string in the + // typed value's data word. + name = poolString(pool, binary.LittleEndian.Uint32(chunk[at+16:])) + } + if name == "" { + return "", false, errors.New(" package attribute is empty") + } + return name, true, nil + } + return "", false, errors.New(" has no package attribute") +} + +func poolString(pool []string, index uint32) string { + if uint64(index) >= uint64(len(pool)) { + return "" + } + return pool[index] +} + +func poolStrings(chunk []byte) ([]string, error) { + const header = 28 + if len(chunk) < header { + return nil, errors.New("truncated string pool") + } + count := uint64(binary.LittleEndian.Uint32(chunk[8:])) + flags := binary.LittleEndian.Uint32(chunk[16:]) + start := uint64(binary.LittleEndian.Uint32(chunk[20:])) + if header+4*count > uint64(len(chunk)) { + return nil, errors.New("string pool offsets run past the chunk") + } + pool := make([]string, count) + for index := uint64(0); index < count; index++ { + at := start + uint64(binary.LittleEndian.Uint32(chunk[header+4*index:])) + value, err := poolString8Or16(chunk, at, flags&stringPoolUTF8 != 0) + if err != nil { + return nil, err + } + pool[index] = value + } + return pool, nil +} + +func poolString8Or16(chunk []byte, at uint64, utf8 bool) (string, error) { + if utf8 { + // The character count precedes the byte count; only the second governs + // how far the string reaches. + at, _, err := prefixLength8(chunk, at) + if err != nil { + return "", err + } + at, length, err := prefixLength8(chunk, at) + if err != nil { + return "", err + } + if at+length > uint64(len(chunk)) { + return "", errStringPoolRange + } + return string(chunk[at : at+length]), nil + } + at, length, err := prefixLength16(chunk, at) + if err != nil { + return "", err + } + if at+2*length > uint64(len(chunk)) { + return "", errStringPoolRange + } + units := make([]uint16, length) + for index := range units { + units[index] = binary.LittleEndian.Uint16(chunk[at+2*uint64(index):]) + } + return string(utf16.Decode(units)), nil +} + +// prefixLength8 reads a UTF-8 pool string's length prefix: one byte, or two +// when the high bit marks the value as wider. It returns the offset past the +// prefix. +func prefixLength8(chunk []byte, at uint64) (uint64, uint64, error) { + if at >= uint64(len(chunk)) { + return 0, 0, errStringPoolRange + } + length := uint64(chunk[at]) + if length&0x80 == 0 { + return at + 1, length, nil + } + if at+1 >= uint64(len(chunk)) { + return 0, 0, errStringPoolRange + } + return at + 2, (length&0x7f)<<8 | uint64(chunk[at+1]), nil +} + +// prefixLength16 reads a UTF-16 pool string's length prefix, in units rather +// than bytes: one 16-bit word, or two when the high bit marks the value as +// wider. +func prefixLength16(chunk []byte, at uint64) (uint64, uint64, error) { + if at+2 > uint64(len(chunk)) { + return 0, 0, errStringPoolRange + } + length := uint64(binary.LittleEndian.Uint16(chunk[at:])) + if length&0x8000 == 0 { + return at + 2, length, nil + } + if at+4 > uint64(len(chunk)) { + return 0, 0, errStringPoolRange + } + return at + 4, (length&0x7fff)<<16 | uint64(binary.LittleEndian.Uint16(chunk[at+2:])), nil +} diff --git a/internal/android/apk_test.go b/internal/android/apk_test.go new file mode 100644 index 0000000..86a3956 --- /dev/null +++ b/internal/android/apk_test.go @@ -0,0 +1,173 @@ +package android + +import ( + "archive/zip" + "bytes" + "encoding/binary" + "os" + "path/filepath" + "strings" + "testing" +) + +// folioAPK is the folio debug APK stripped to the one entry the reader looks +// at, so the parse runs against a manifest aapt2 really produced. +const folioAPK = "testdata/folio.apk" + +func TestPackageName_ReadsCompiledManifest(t *testing.T) { + name, err := PackageName(folioAPK) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "app.folio" { + t.Fatalf("package name: got %q, want app.folio", name) + } +} + +func TestPackageName_UTF8StringPool(t *testing.T) { + name, err := PackageName(apkWithManifest(t, utf8PooledManifest("com.example.utf8"))) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if name != "com.example.utf8" { + t.Fatalf("package name: got %q, want com.example.utf8", name) + } +} + +func TestPackageName_NotAnAPK(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.apk") + if err := os.WriteFile(path, []byte("this is not a zip"), 0o600); err != nil { + t.Fatalf("write file: %v", err) + } + _, err := PackageName(path) + if err == nil || !strings.Contains(err.Error(), path) { + t.Fatalf("expected an error naming %s, got %v", path, err) + } +} + +func TestPackageName_NoManifestEntry(t *testing.T) { + path := filepath.Join(t.TempDir(), "app.apk") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create apk: %v", err) + } + archive := zip.NewWriter(file) + if _, err := archive.Create("classes.dex"); err != nil { + t.Fatalf("write entry: %v", err) + } + if err := archive.Close(); err != nil { + t.Fatalf("close archive: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close apk: %v", err) + } + _, err = PackageName(path) + if err == nil || !strings.Contains(err.Error(), "AndroidManifest.xml") { + t.Fatalf("expected a missing-manifest error, got %v", err) + } +} + +func TestPackageName_TruncatedManifest(t *testing.T) { + manifest, err := manifestFromAPK(folioAPK) + if err != nil { + t.Fatalf("read fixture manifest: %v", err) + } + for _, keep := range []int{4, 40, 400} { + if _, err := PackageName(apkWithManifest(t, manifest[:keep])); err == nil { + t.Fatalf("expected an error from a manifest cut to %d bytes", keep) + } + } +} + +func apkWithManifest(t *testing.T, manifest []byte) string { + t.Helper() + path := filepath.Join(t.TempDir(), "app.apk") + file, err := os.Create(path) + if err != nil { + t.Fatalf("create apk: %v", err) + } + archive := zip.NewWriter(file) + entry, err := archive.Create(manifestEntry) + if err != nil { + t.Fatalf("create manifest entry: %v", err) + } + if _, err := entry.Write(manifest); err != nil { + t.Fatalf("write manifest entry: %v", err) + } + if err := archive.Close(); err != nil { + t.Fatalf("close archive: %v", err) + } + if err := file.Close(); err != nil { + t.Fatalf("close apk: %v", err) + } + return path +} + +// utf8PooledManifest encodes the smallest manifest that carries a package +// name, with the UTF-8 string pool that aapt2 does not emit and older builders +// do. Without it the UTF-8 decoding path is never exercised: the compiled +// manifests aapt2 writes all pool their strings as UTF-16. +func utf8PooledManifest(packageName string) []byte { + pool := []string{"manifest", "package", packageName} + var pooled bytes.Buffer + offsets := make([]uint32, len(pool)) + for index, value := range pool { + offsets[index] = uint32(pooled.Len()) + pooled.WriteByte(byte(len(value))) + pooled.WriteByte(byte(len(value))) + pooled.WriteString(value) + pooled.WriteByte(0) + } + stringsStart := uint32(28 + 4*len(pool)) + + var poolChunk bytes.Buffer + write16(&poolChunk, chunkStringPool) + write16(&poolChunk, 28) + write32(&poolChunk, stringsStart+uint32(pooled.Len())) + write32(&poolChunk, uint32(len(pool))) + write32(&poolChunk, 0) + write32(&poolChunk, stringPoolUTF8) + write32(&poolChunk, stringsStart) + write32(&poolChunk, 0) + for _, offset := range offsets { + write32(&poolChunk, offset) + } + poolChunk.Write(pooled.Bytes()) + + var element bytes.Buffer + write16(&element, chunkStartElement) + write16(&element, 16) + write32(&element, 36+20) + write32(&element, 1) // line number + write32(&element, 0xFFFFFFFF) // comment + write32(&element, 0xFFFFFFFF) // namespace + write32(&element, 0) // name: "manifest" + write16(&element, 20) // attributes start, past this header + write16(&element, 20) // attribute stride + write16(&element, 1) // attribute count + write16(&element, 0) // id index + write16(&element, 0) // class index + write16(&element, 0) // style index + write32(&element, 0xFFFFFFFF) // attribute namespace + write32(&element, 1) // attribute name: "package" + write32(&element, 2) // attribute raw value: the package name + write16(&element, 8) // typed value size + write16(&element, 3<<8) // res0, and TYPE_STRING + write32(&element, 2) + + var manifest bytes.Buffer + write16(&manifest, 3) + write16(&manifest, 8) + write32(&manifest, uint32(8+poolChunk.Len()+element.Len())) + manifest.Write(poolChunk.Bytes()) + manifest.Write(element.Bytes()) + return manifest.Bytes() +} + +func write16(buffer *bytes.Buffer, value uint16) { + _ = binary.Write(buffer, binary.LittleEndian, value) +} + +func write32(buffer *bytes.Buffer, value uint32) { + _ = binary.Write(buffer, binary.LittleEndian, value) +} diff --git a/internal/android/testdata/folio.apk b/internal/android/testdata/folio.apk new file mode 100644 index 0000000000000000000000000000000000000000..c3653d24818790a68c77ff601ce36e6f7aa16ebb GIT binary patch literal 1759 zcmV<51|a!RO9KQH0000803iq=0*&g>J68q(0B98e022TJ06}hKa&Kv5O<`_nW@U49 zE_iKhtyfD^TvZV6JHx;vBrtiC2apGW1i~dDBu0EOJfavP6ktkMc$tAP$?%XF@+wPH zDHpCR7bzvuz{wrR6PN{V0UrU6fggasfYeT+4qynF2NG1IC0d|mfJb=> zXdcgbjOr*y8CsROsKehtwRX}P?&}!ksDNKib($ex$8!;HGx!_ICosCB9vg695r#aT zvf?X?r+M0-oA{YGgvu?@J@{LN$D-;s;{Ari%hH@MEP$VAx5l{$$xRx8Mu5>xeVYJ( z0KXgJdAG#9LNoArn{G%Zr5qY1){ zX9M|hrt8rC5Tl$tai)7D`y$4H%p`Mhwj99(?(>pYGju~6))|Ixp8GTy0+F&Go|k3t zV>?`fmYIiA7wp5+8h#^c%Ub-tNLMv#uF`#I?x6GJ3ocMEWEojyF^n!tb*@4) zFFZu`YHMOkZjSFr#qUcOadorOYdM*(BaXzE3KumVe~V9_u49hIF{fBzRqQe{?+d7G zn2V3uPt&`w9ig{qjHcn^P??XY)#1}9?5@)kEIJ*Fd&0CTHS={=eVJF5RG;6T2!Fhy zSLqhU{TLOspM1n@|2;U2Q6y~y<_$ok65czu)2XpC>sm=@jzT`SCSY1=tiqf$l#FT}RBKZjAJu*0}h zr|BBa(Ig_`eoNCVxF@B1Uu^caa`8HtfzLFmHUXX~a7?1Z#xWWLNAEUp%)t8%cq{kb zy)E9a!QT|Tm;9z8{1$YVF6zG9RaM82sB*B$9CpYZu?=hQ?n3PGu4Isvec#trxF9)& zUJ4OANV#1>SuzqG@2F;%ShV)e{s<}WJMW{$Y4p-KdTI_87{h%GqZ`sub9g(64w%E} zJ!CZr*({z%rCzMTmNWR7zB?Gna~)ESIbF72ABog=bM7sxP@~%AYt$9-Xujr1)8E|!$ z z=q`9FNOrwai+MShgtOjky^GE!X=xPXWWm=%FDbK&)4AsXF(3}q13Q2wpczO3EkHZa z0qlx--cjHfum(H?J_mlU^SngD^VWd^@F2l%Q z3F)9xyl-f{F)Q9(mR*a=njQafofFrW;b^qvS0Xs#aw-UO@Ku&5aqNj%9Ih|sxMIb` z*XzpR{RfBf=h~S%?y=+-BI-z`_`Gy1?y-#d4lBoD(-)Q=-h6V=nkUHi@||TVOJ+Fs zM#v5lpD;s?;~MV=%4_01s`191{QK0dPi4u7ICfg|{^1-&}Qiz zsZfJfjiJ@5foo^{9k=BD75ufSzc$OCYiHzc4o2?Io$1>SOa2-~_ic~z_o&SDjj5U8 zaD5q$t{OPbDaW}g9Ih|Z8$Fi%l?uJltsLDEyhEL~4%e6AxKsm2hx+TN!r}Tda<>-D{Tc3qmi$%aPB`4B zl;@Pn>VS5YwX3Y2%GY$%I^XBi*R1m)s=m(CRDGRyYFwRG?yjAYyPPJLks4wdts#~( z8p|1r)3q~lm(#>@wT4*UtRa>zjit-tbnT4XxYcx^_nHavHhY zw?^)MKaJcyA9)qdQ~v@`O928N0~7!N00;mf2q6NE>d-q^1^@tP6#xJe0000000000 z0001_fdBvi06}hKa&Kv5O<`_nW@U49E_iKhP)h{{000000RRC2K>z>%hz0-v006L* BM?e4o literal 0 HcmV?d00001