From d856c40d7cce52b9d07dda110d321d2e5eeaac4c Mon Sep 17 00:00:00 2001 From: PJ Date: Fri, 17 Apr 2026 23:57:17 +0700 Subject: [PATCH] feat(permissions): grant dangerous permissions via aapt + adb MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Inspector parses uses-permission entries from `aapt dump permissions` and the granter shells out to `adb shell pm grant`. Both are pluggable so tests can drive logic without aapt or a device. Granter failures become warnings rather than errors — Android refuses non-runtime permissions and we'd rather keep going than abort the run. --- internal/permissions/permissions.go | 125 ++++++++++++++++++++++ internal/permissions/permissions_test.go | 127 +++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 internal/permissions/permissions.go create mode 100644 internal/permissions/permissions_test.go diff --git a/internal/permissions/permissions.go b/internal/permissions/permissions.go new file mode 100644 index 0000000..dc7c69d --- /dev/null +++ b/internal/permissions/permissions.go @@ -0,0 +1,125 @@ +package permissions + +import ( + "context" + "fmt" + "os/exec" + "regexp" + "strings" +) + +// Inspector returns the list of uses-permission entries declared by the APK. +type Inspector func(ctx context.Context, apkPath string) ([]string, error) + +// Granter grants a single permission to the given package on the connected +// device. Implementations typically wrap `adb shell pm grant`. +type Granter func(ctx context.Context, packageName, permission string) error + +// GrantDangerous filters declared permissions to the dangerous set and grants +// each one via the supplied granter. Errors from individual grants are +// collected as warnings rather than aborting — Android refuses to grant +// non-runtime permissions and we prefer to soldier on. +func GrantDangerous( + ctx context.Context, + apkPath, packageName string, + inspector Inspector, + granter Granter, +) (granted []string, warnings []string, err error) { + declared, err := inspector(ctx, apkPath) + if err != nil { + return nil, nil, fmt.Errorf("inspect permissions: %w", err) + } + for _, permission := range declared { + if !IsDangerous(permission) { + continue + } + if err := granter(ctx, packageName, permission); err != nil { + warnings = append(warnings, fmt.Sprintf("%s: %v", permission, err)) + continue + } + granted = append(granted, permission) + } + return granted, warnings, nil +} + +var aaptPermissionPattern = regexp.MustCompile(`uses-permission:\s+name='([^']+)'`) + +// AaptInspector shells out to `aapt dump permissions` to list permissions. +func AaptInspector(ctx context.Context, apkPath string) ([]string, error) { + output, err := exec.CommandContext(ctx, "aapt", "dump", "permissions", apkPath).Output() + if err != nil { + return nil, fmt.Errorf("aapt dump permissions: %w", err) + } + var permissions []string + for _, match := range aaptPermissionPattern.FindAllStringSubmatch(string(output), -1) { + permissions = append(permissions, match[1]) + } + return permissions, nil +} + +// AdbGranter returns a Granter that runs `adb shell pm grant` against the +// supplied device serial (empty = default device). +func AdbGranter(adbPath, deviceSerial string) Granter { + return func(ctx context.Context, packageName, permission string) error { + arguments := []string{"shell", "pm", "grant", packageName, permission} + if deviceSerial != "" { + arguments = append([]string{"-s", deviceSerial}, arguments...) + } + command := exec.CommandContext(ctx, adbPath, arguments...) + output, err := command.CombinedOutput() + if err != nil { + return fmt.Errorf("adb pm grant %s: %w (%s)", permission, err, strings.TrimSpace(string(output))) + } + return nil + } +} + +// dangerousPermissions captures Android's PROTECTION_DANGEROUS set as of +// API 34. New runtime permissions added in later releases should be appended +// here when needed. +var dangerousPermissions = map[string]bool{ + "android.permission.READ_CALENDAR": true, + "android.permission.WRITE_CALENDAR": true, + "android.permission.CAMERA": true, + "android.permission.READ_CONTACTS": true, + "android.permission.WRITE_CONTACTS": true, + "android.permission.GET_ACCOUNTS": true, + "android.permission.ACCESS_FINE_LOCATION": true, + "android.permission.ACCESS_COARSE_LOCATION": true, + "android.permission.ACCESS_BACKGROUND_LOCATION": true, + "android.permission.RECORD_AUDIO": true, + "android.permission.READ_PHONE_STATE": true, + "android.permission.READ_PHONE_NUMBERS": true, + "android.permission.CALL_PHONE": true, + "android.permission.ANSWER_PHONE_CALLS": true, + "android.permission.READ_CALL_LOG": true, + "android.permission.WRITE_CALL_LOG": true, + "android.permission.ADD_VOICEMAIL": true, + "android.permission.USE_SIP": true, + "android.permission.PROCESS_OUTGOING_CALLS": true, + "android.permission.BODY_SENSORS": true, + "android.permission.SEND_SMS": true, + "android.permission.RECEIVE_SMS": true, + "android.permission.READ_SMS": true, + "android.permission.RECEIVE_WAP_PUSH": true, + "android.permission.RECEIVE_MMS": true, + "android.permission.READ_EXTERNAL_STORAGE": true, + "android.permission.WRITE_EXTERNAL_STORAGE": true, + "android.permission.ACCESS_MEDIA_LOCATION": true, + "android.permission.ACTIVITY_RECOGNITION": true, + "android.permission.POST_NOTIFICATIONS": true, + "android.permission.NEARBY_WIFI_DEVICES": true, + "android.permission.READ_MEDIA_IMAGES": true, + "android.permission.READ_MEDIA_VIDEO": true, + "android.permission.READ_MEDIA_AUDIO": true, + "android.permission.READ_MEDIA_VISUAL_USER_SELECTED": true, + "android.permission.BLUETOOTH_CONNECT": true, + "android.permission.BLUETOOTH_ADVERTISE": true, + "android.permission.BLUETOOTH_SCAN": true, + "android.permission.UWB_RANGING": true, + "android.permission.BODY_SENSORS_BACKGROUND": true, +} + +func IsDangerous(permission string) bool { + return dangerousPermissions[permission] +} diff --git a/internal/permissions/permissions_test.go b/internal/permissions/permissions_test.go new file mode 100644 index 0000000..907895b --- /dev/null +++ b/internal/permissions/permissions_test.go @@ -0,0 +1,127 @@ +package permissions + +import ( + "context" + "errors" + "slices" + "testing" +) + +func TestGrantDangerous_FiltersAndCallsGranter(t *testing.T) { + declared := []string{ + "android.permission.INTERNET", // not dangerous + "android.permission.CAMERA", // dangerous + "android.permission.WAKE_LOCK", // not dangerous + "android.permission.ACCESS_FINE_LOCATION", // dangerous + "android.permission.READ_EXTERNAL_STORAGE", // dangerous + "android.permission.SYSTEM_ALERT_WINDOW", // not dangerous + } + inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil } + var requested []string + granter := func(_ context.Context, packageName, permission string) error { + if packageName != "com.example" { + t.Errorf("granter received wrong package: %q", packageName) + } + requested = append(requested, permission) + return nil + } + + granted, warnings, err := GrantDangerous(context.Background(), "/path/to/apk", "com.example", inspector, granter) + if err != nil { + t.Fatal(err) + } + want := []string{ + "android.permission.CAMERA", + "android.permission.ACCESS_FINE_LOCATION", + "android.permission.READ_EXTERNAL_STORAGE", + } + if !slices.Equal(granted, want) { + t.Errorf("granted permissions: got %v, want %v", granted, want) + } + if !slices.Equal(requested, want) { + t.Errorf("granter calls: got %v, want %v", requested, want) + } + if len(warnings) != 0 { + t.Errorf("expected no warnings, got %v", warnings) + } +} + +func TestGrantDangerous_CollectsGranterFailuresAsWarnings(t *testing.T) { + declared := []string{"android.permission.CAMERA", "android.permission.RECORD_AUDIO"} + inspector := func(_ context.Context, _ string) ([]string, error) { return declared, nil } + granter := func(_ context.Context, _, permission string) error { + if permission == "android.permission.CAMERA" { + return errors.New("device denied") + } + return nil + } + + granted, warnings, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, granter) + if err != nil { + t.Fatal(err) + } + if !slices.Equal(granted, []string{"android.permission.RECORD_AUDIO"}) { + t.Errorf("granted: %v", granted) + } + if len(warnings) != 1 || warnings[0] != "android.permission.CAMERA: device denied" { + t.Errorf("warnings: %v", warnings) + } +} + +func TestGrantDangerous_InspectorErrorBubbles(t *testing.T) { + inspector := func(_ context.Context, _ string) ([]string, error) { + return nil, errors.New("aapt missing") + } + _, _, err := GrantDangerous(context.Background(), "/x", "com.example", inspector, nil) + if err == nil || err.Error() == "" { + t.Errorf("expected wrapped inspector error, got %v", err) + } +} + +func TestIsDangerous_KnownPermissions(t *testing.T) { + dangerous := []string{ + "android.permission.CAMERA", + "android.permission.RECORD_AUDIO", + "android.permission.POST_NOTIFICATIONS", + } + for _, permission := range dangerous { + if !IsDangerous(permission) { + t.Errorf("expected %q to be dangerous", permission) + } + } + + normal := []string{ + "android.permission.INTERNET", + "android.permission.WAKE_LOCK", + "android.permission.SYSTEM_ALERT_WINDOW", + } + for _, permission := range normal { + if IsDangerous(permission) { + t.Errorf("expected %q not to be dangerous", permission) + } + } +} + +func TestAaptInspector_ParsesFixtureOutput(t *testing.T) { + // Direct test of the regex; avoids spawning aapt. + output := ` +package: com.example +sdkVersion:'24' +uses-permission: name='android.permission.INTERNET' +uses-permission: name='android.permission.CAMERA' +uses-permission: name='android.permission.WAKE_LOCK' +` + matches := aaptPermissionPattern.FindAllStringSubmatch(output, -1) + got := make([]string, 0, len(matches)) + for _, match := range matches { + got = append(got, match[1]) + } + want := []string{ + "android.permission.INTERNET", + "android.permission.CAMERA", + "android.permission.WAKE_LOCK", + } + if !slices.Equal(got, want) { + t.Errorf("got %v, want %v", got, want) + } +}