Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 24 additions & 1 deletion devices/android_fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,21 @@ import (
"github.com/google/uuid"
)

// androidContainerPath turns a bundle id and a path relative to that app's data
// container into an absolute /data/user/0/<pkg>/... path. An empty relative path
// yields the container root. Living under /data/user/ is what makes
// buildShellCommand wrap the operation in `run-as <pkg>`, which is how a
// debuggable app's own files are reached; a bare "/files" would otherwise be
// read as an absolute device path and fail.
func androidContainerPath(bundleID, remotePath string) string {
base := "/data/user/0/" + bundleID
rel := strings.TrimPrefix(remotePath, "/")
if rel == "" {
return base
}
return base + "/" + rel
}

// androidPackageName extracts the package name from a /data/user/<uid>/<package>/... path.
func androidPackageName(remotePath string) (string, error) {
parts := strings.SplitN(remotePath, "/", 6)
Expand Down Expand Up @@ -93,7 +108,9 @@ func (d *AndroidDevice) PullFile(remotePath, localPath string) error {
}

func (d *AndroidDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) {
if remotePath == "" {
if bundleID != "" {
remotePath = androidContainerPath(bundleID, remotePath)
} else if remotePath == "" {
remotePath = "/"
}

Expand Down Expand Up @@ -193,6 +210,9 @@ func androidParseLsLine(line, dirPath string) *FileEntry {
}

func (d *AndroidDevice) Mkdir(bundleID, remotePath string, parents bool) error {
if bundleID != "" {
remotePath = androidContainerPath(bundleID, remotePath)
}
parts := []string{"mkdir"}
if parents {
parts = append(parts, "-p")
Expand All @@ -207,6 +227,9 @@ func (d *AndroidDevice) Mkdir(bundleID, remotePath string, parents bool) error {
}

func (d *AndroidDevice) Rm(bundleID, remotePath string, recursive bool) error {
if bundleID != "" {
remotePath = androidContainerPath(bundleID, remotePath)
}
parts := []string{"rm"}
if recursive {
parts = append(parts, "-rf")
Expand Down
40 changes: 40 additions & 0 deletions devices/android_fs_container_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package devices

import "testing"

func TestAndroidContainerPathBuildsDataUserPath(t *testing.T) {
cases := []struct {
bundle, rel, want string
}{
{"com.example.app", "/files", "/data/user/0/com.example.app/files"},
{"com.example.app", "files", "/data/user/0/com.example.app/files"},
{"com.example.app", "", "/data/user/0/com.example.app"},
{"com.example.app", "/databases/x.db", "/data/user/0/com.example.app/databases/x.db"},
}
for _, c := range cases {
if got := androidContainerPath(c.bundle, c.rel); got != c.want {
t.Errorf("androidContainerPath(%q,%q) = %q, want %q", c.bundle, c.rel, got, c.want)
}
}
}

// the container path must live under /data/user/, which is what makes
// buildShellCommand wrap the command in run-as and androidPackageName recover
// the package to run as.
func TestAndroidContainerPathIsRunAsWrapped(t *testing.T) {
p := androidContainerPath("com.example.app", "/files/x")
pkg, err := androidPackageName(p)
if err != nil {
t.Fatalf("androidPackageName(%q) error: %v", p, err)
}
if pkg != "com.example.app" {
t.Errorf("recovered package = %q, want com.example.app", pkg)
}
cmd, err := (&AndroidDevice{}).buildShellCommand(p, "ls", p)
if err != nil {
t.Fatal(err)
}
if got := cmd[:7]; got != "run-as " {
t.Errorf("command for a container path = %q…, want it to start with 'run-as '", got)
}
}
48 changes: 35 additions & 13 deletions devices/simulator_fs.go
Original file line number Diff line number Diff line change
Expand Up @@ -100,21 +100,35 @@ func (s *SimulatorDevice) GetAppContainerPath(bundleID string) (string, error) {
return strings.TrimSpace(string(output)), nil
}

func (s *SimulatorDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) {
// resolveContainerRelativePath turns a (bundleID, remotePath) pair into an
// absolute path inside the simulator's device directory. When a bundle id is
// given, remotePath is taken as relative to that app's data container (so
// `fs ls <bundle> /Documents` lists the app's Documents, as the help shows); an
// empty remotePath yields the container root. With no bundle id, an empty
// remotePath defaults to the device's data directory and any other path is used
// as-is.
func (s *SimulatorDevice) resolveContainerRelativePath(bundleID, remotePath string) (string, error) {
if bundleID != "" {
container, err := s.GetAppContainerPath(bundleID)
if err != nil {
return "", err
}
return filepath.Join(container, filepath.Clean("/"+remotePath)), nil
}
if remotePath == "" {
if bundleID != "" {
var err error
remotePath, err = s.GetAppContainerPath(bundleID)
if err != nil {
return nil, err
}
} else {
root, err := s.simulatorDeviceRoot()
if err != nil {
return nil, err
}
remotePath = filepath.Join(root, "data")
root, err := s.simulatorDeviceRoot()
if err != nil {
return "", err
}
return filepath.Join(root, "data"), nil
}
return remotePath, nil
}

func (s *SimulatorDevice) ListFiles(bundleID, remotePath string) ([]FileEntry, error) {
remotePath, err := s.resolveContainerRelativePath(bundleID, remotePath)
if err != nil {
return nil, err
}

if err := s.validatePath(remotePath); err != nil {
Expand Down Expand Up @@ -181,6 +195,10 @@ func (s *SimulatorDevice) PushFile(localPath, remotePath string) error {
}

func (s *SimulatorDevice) Mkdir(bundleID, remotePath string, parents bool) error {
remotePath, err := s.resolveContainerRelativePath(bundleID, remotePath)
if err != nil {
return err
}
if err := s.validatePath(remotePath); err != nil {
return err
}
Expand All @@ -191,6 +209,10 @@ func (s *SimulatorDevice) Mkdir(bundleID, remotePath string, parents bool) error
}

func (s *SimulatorDevice) Rm(bundleID, remotePath string, recursive bool) error {
remotePath, err := s.resolveContainerRelativePath(bundleID, remotePath)
if err != nil {
return err
}
if err := s.validatePath(remotePath); err != nil {
return err
}
Expand Down
28 changes: 28 additions & 0 deletions test/android.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -794,6 +794,34 @@ test.describe('Android Tests', () => {
const names = entries.map((e: any) => e.name);
expect(names).not.toContain('mobilecli-test');
});

// the documented form: `fs ls <bundle-id> <path>` with a path relative to
// the container, rather than the absolute /data/user/0/<pkg>/... path
test('should address the container by bundle id and a relative path', () => {
test.skip(!device, 'No Android device found');

// a bundle id with no path lists the container root, not the device root
// (android's mobilecli() helper already returns the data payload)
const rootNames = mobilecli(['fs', 'ls', packageName, '--device', device!.id]).map((e: any) => e.name);
expect(rootNames).toContain('files');
expect(rootNames).not.toContain('sdcard'); // that would be the device root

// <bundle> /files matches the absolute container listing
const byBundle = mobilecli(['fs', 'ls', packageName, '/files', '--device', device!.id]);
const byPath = fsList(device!.id, `${containerPath}/files`);
expect(byBundle.map((e: any) => e.name).sort()).toEqual(byPath.map((e: any) => e.name).sort());

// create and remove a directory through the bundle+relative form
const relName = `mobilecli-rel-${Date.now()}`;
mobilecli(['fs', 'mkdir', packageName, `/files/${relName}`, '--device', device!.id]);
expect(fsList(device!.id, `${containerPath}/files`).map((e: any) => e.name)).toContain(relName);
mobilecli(['fs', 'rm', packageName, `/files/${relName}`, '-r', '--device', device!.id]);
});

test('should fail to list a container for an uninstalled package', () => {
test.skip(!device, 'No Android device found');
expect(() => mobilecli(['fs', 'ls', 'com.mobilecli.does.not.exist', '/files', '--device', device!.id])).toThrow();
});
});
});

Expand Down
20 changes: 20 additions & 0 deletions test/simulator.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -743,6 +743,26 @@ test.describe('iOS Simulator Tests', () => {
expect(names).not.toContain('mobilecli-test');
});

// the documented form: `fs ls <bundle-id> <path>` with a path relative
// to the container, not the absolute host container path
test('should address the container by bundle id and a relative path', async () => {
test.skip(!simulatorId, 'simulator not found');

// <bundle> /Documents matches the absolute container listing
const byBundle = mobilecli(['fs', 'ls', packageName, '/Documents', '--device', simulatorId]).data;
const byPath = fsList(simulatorId, `${containerPath}/Documents`);
expect(byBundle.map((e: any) => e.name).sort()).toEqual(byPath.map((e: any) => e.name).sort());

// create and remove a directory through the bundle+relative form
const relName = `mobilecli-rel-${Date.now()}`;
mobilecli(['fs', 'mkdir', packageName, `/Documents/${relName}`, '--device', simulatorId]);
expect(fsList(simulatorId, `${containerPath}/Documents`).map((e: any) => e.name)).toContain(relName);
mobilecli(['fs', 'rm', packageName, `/Documents/${relName}`, '-r', '--device', simulatorId]);

// a .. in the relative path must not climb out of the container
expect(() => mobilecli(['fs', 'ls', packageName, '/../../../../../../etc', '--device', simulatorId])).toThrow();
});

test('should prevent escaping the app container sandbox', async () => {
test.skip(!simulatorId, 'simulator not found');
const localDest = path.join(os.tmpdir(), `mobilecli-pull-app-${Date.now()}.txt`);
Expand Down
Loading