diff --git a/acquisition/acquisition.go b/acquisition/acquisition.go index f68c8f1..ed6af9c 100644 --- a/acquisition/acquisition.go +++ b/acquisition/acquisition.go @@ -19,7 +19,6 @@ import ( "github.com/botherder/go-savetime/hashes" "github.com/google/uuid" "github.com/mvt-project/androidqf/adb" - "github.com/mvt-project/androidqf/assets" "github.com/mvt-project/androidqf/log" "github.com/mvt-project/androidqf/utils" ) @@ -180,12 +179,6 @@ func (a *Acquisition) Complete() { if a.Collector != nil { a.Collector.Clean() } - - // Stop ADB server before trying to remove extracted assets - if adb.Client != nil { - adb.Client.KillServer() - } - assets.CleanAssets() } func (a *Acquisition) GetSystemInformation() error { diff --git a/adb/adb.go b/adb/adb.go index 253cde7..3bdcf5f 100644 --- a/adb/adb.go +++ b/adb/adb.go @@ -20,6 +20,14 @@ type ADB struct { Serial string } +type DeviceInfo struct { + Serial string + State string + Product string + Model string + Device string +} + var Client *ADB // New returns a new ADB instance. @@ -32,9 +40,6 @@ func New() (*ADB, error) { } log.Debugf("ADB found at path: %s", adb.ExePath) - log.Debug("Killing existing ADB server if running") - adb.KillServer() - // Confirm that we can call "adb devices" without errors _, err = adb.Devices() if err != nil { @@ -63,11 +68,10 @@ func (a *ADB) SetSerial(serial string) (string, error) { } a.Serial = serial } else { - // Problem if multiple devices if len(devices) > 1 { return "", fmt.Errorf("multiple devices connected, please stop AndroidQF and provide a serial number") } - a.Serial = "" + a.Serial = devices[0] } return a.Serial, nil } @@ -93,6 +97,56 @@ func (a *ADB) Devices() ([]string, error) { return devices, nil } +func (a *ADB) DeviceInfos() ([]DeviceInfo, error) { + var devices []DeviceInfo + out, err := exec.Command(a.ExePath, "devices", "-l").Output() + if err != nil { + return devices, fmt.Errorf("failed to use the adb executable: %v", + err) + } + + lines := strings.Split(string(out), "\n") + for _, line := range lines[1:] { + info, ok := parseDeviceInfoLine(line) + if !ok { + continue + } + devices = append(devices, info) + log.Debug("Found new device: ", info.Serial) + } + + return devices, nil +} + +func parseDeviceInfoLine(line string) (DeviceInfo, bool) { + fields := strings.Fields(line) + if len(fields) < 2 { + return DeviceInfo{}, false + } + + info := DeviceInfo{ + Serial: fields[0], + State: fields[1], + } + for _, field := range fields[2:] { + key, value, ok := strings.Cut(field, ":") + if !ok { + continue + } + + switch key { + case "product": + info.Product = value + case "model": + info.Model = value + case "device": + info.Device = value + } + } + + return info, true +} + // Run a command to the given phone using exec // Returns string and/or error func (a *ADB) Exec(args ...string) ([]byte, error) { diff --git a/adb/adb_test.go b/adb/adb_test.go new file mode 100644 index 0000000..10a87bb --- /dev/null +++ b/adb/adb_test.go @@ -0,0 +1,112 @@ +package adb + +import ( + "fmt" + "os" + "strings" + "testing" +) + +func TestMain(m *testing.M) { + if os.Getenv("ANDROIDQF_FAKE_ADB") == "1" { + fakeADB() + return + } + os.Exit(m.Run()) +} + +func fakeADB() { + if len(os.Args) < 2 { + os.Exit(2) + } + + switch os.Args[1] { + case "devices": + fmt.Println("List of devices attached") + for _, device := range strings.Split(os.Getenv("ANDROIDQF_FAKE_ADB_DEVICES"), ",") { + device = strings.TrimSpace(device) + if device != "" { + if len(os.Args) > 2 && os.Args[2] == "-l" { + fmt.Printf("%s device product:fake model:%s_Model device:%s transport_id:1\n", device, device, device) + } else { + fmt.Printf("%s\tdevice\n", device) + } + } + } + default: + os.Exit(2) + } +} + +func TestDeviceInfosParsesLongDeviceList(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + devices, err := client.DeviceInfos() + if err != nil { + t.Fatalf("DeviceInfos returned error: %v", err) + } + if len(devices) != 2 { + t.Fatalf("DeviceInfos returned %d devices, want 2", len(devices)) + } + if devices[0].Serial != "device-1" { + t.Fatalf("first serial = %q, want device-1", devices[0].Serial) + } + if devices[0].Model != "device-1_Model" { + t.Fatalf("first model = %q, want device-1_Model", devices[0].Model) + } +} + +func TestParseDeviceInfoLineUnauthorizedWithoutModel(t *testing.T) { + info, ok := parseDeviceInfoLine("5B221JEBF18336 unauthorized usb:336592896X transport_id:1") + if !ok { + t.Fatal("parseDeviceInfoLine returned ok=false") + } + if info.Serial != "5B221JEBF18336" { + t.Fatalf("serial = %q, want 5B221JEBF18336", info.Serial) + } + if info.State != "unauthorized" { + t.Fatalf("state = %q, want unauthorized", info.State) + } + if info.Model != "" { + t.Fatalf("model = %q, want empty", info.Model) + } +} + +func newFakeADB(t *testing.T, devices string) *ADB { + t.Helper() + t.Setenv("ANDROIDQF_FAKE_ADB", "1") + t.Setenv("ANDROIDQF_FAKE_ADB_DEVICES", devices) + return &ADB{ExePath: os.Args[0]} +} + +func TestSetSerialSingleDeviceUsesExplicitSerial(t *testing.T) { + client := newFakeADB(t, "device-1") + serial, err := client.SetSerial("") + if err != nil { + t.Fatalf("SetSerial returned error: %v", err) + } + if serial != "device-1" { + t.Fatalf("serial = %q, want device-1", serial) + } + if client.Serial != "device-1" { + t.Fatalf("client.Serial = %q, want device-1", client.Serial) + } +} + +func TestSetSerialMultipleDevicesWithoutSerialErrors(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + _, err := client.SetSerial("") + if err == nil { + t.Fatal("SetSerial returned nil error, want multiple devices error") + } +} + +func TestSetSerialExplicitSerial(t *testing.T) { + client := newFakeADB(t, "device-1,device-2") + serial, err := client.SetSerial("device-2") + if err != nil { + t.Fatalf("SetSerial returned error: %v", err) + } + if serial != "device-2" { + t.Fatalf("serial = %q, want device-2", serial) + } +} diff --git a/device_selection_test.go b/device_selection_test.go new file mode 100644 index 0000000..1c79197 --- /dev/null +++ b/device_selection_test.go @@ -0,0 +1,139 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/mvt-project/androidqf/adb" +) + +func TestResolveADBSerialNoDevicesDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("", nil, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "" { + t.Fatalf("serial = %q, want empty", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for zero devices") + } +} + +func TestResolveADBSerialSingleDeviceDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}}, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "device-1" { + t.Fatalf("serial = %q, want device-1", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for one device") + } +} + +func TestResolveADBSerialMultipleDevicesPromptsWithRunningStatus(t *testing.T) { + started := time.Date(2026, 7, 7, 10, 11, 12, 0, time.UTC) + running := map[string]runningExtraction{ + "device-2": { + Serial: "device-2", + PID: 1234, + Started: started, + }, + } + + var gotItems []deviceMenuItem + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{ + {Serial: "device-1", State: "device", Model: "Pixel_9a"}, + {Serial: "device-2", State: "device", Model: "XQ_DC54"}, + }, func(items []deviceMenuItem) (string, error) { + gotItems = items + return items[1].Serial, nil + }, running) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "device-2" { + t.Fatalf("serial = %q, want device-2", serial) + } + if !prompted { + t.Fatal("prompted = false, want true") + } + if len(gotItems) != 2 { + t.Fatalf("selector got %d items, want 2", len(gotItems)) + } + if gotItems[0].Status != "" { + t.Fatalf("first item status = %q, want empty", gotItems[0].Status) + } + if gotItems[0].Title != "Pixel 9a (device-1)" { + t.Fatalf("first item title = %q, want Pixel 9a (device-1)", gotItems[0].Title) + } + if gotItems[1].Status == "" { + t.Fatal("second item status is empty, want running extraction status") + } +} + +func TestResolveADBSerialMultipleDevicesReturnsSelectorError(t *testing.T) { + wantErr := errors.New("selection failed") + serial, prompted, err := resolveADBSerial("", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) { + return "", wantErr + }, nil) + if !errors.Is(err, wantErr) { + t.Fatalf("err = %v, want %v", err, wantErr) + } + if serial != "" { + t.Fatalf("serial = %q, want empty", serial) + } + if !prompted { + t.Fatal("prompted = false, want true") + } +} + +func TestResolveADBSerialExplicitSerialDoesNotPrompt(t *testing.T) { + called := false + serial, prompted, err := resolveADBSerial("requested", []adb.DeviceInfo{{Serial: "device-1"}, {Serial: "device-2"}}, func([]deviceMenuItem) (string, error) { + called = true + return "", nil + }, nil) + if err != nil { + t.Fatalf("resolveADBSerial returned error: %v", err) + } + if serial != "requested" { + t.Fatalf("serial = %q, want requested", serial) + } + if prompted { + t.Fatal("prompted = true, want false") + } + if called { + t.Fatal("selector was called for explicit serial") + } +} + +func TestBuildDeviceMenuItemsFallsBackForUnauthorizedDevice(t *testing.T) { + items := buildDeviceMenuItems([]adb.DeviceInfo{{Serial: "device-1", State: "unauthorized"}}, nil) + if len(items) != 1 { + t.Fatalf("got %d items, want 1", len(items)) + } + if items[0].Title != "device-1" { + t.Fatalf("title = %q, want device-1", items[0].Title) + } + if items[0].Status != "(unauthorized)" { + t.Fatalf("status = %q, want (unauthorized)", items[0].Status) + } +} diff --git a/main.go b/main.go index 213745d..d830b61 100644 --- a/main.go +++ b/main.go @@ -13,6 +13,7 @@ import ( "time" "github.com/i582/cfmt/cmd/cfmt" + "github.com/manifoldco/promptui" "github.com/mvt-project/androidqf/acquisition" "github.com/mvt-project/androidqf/adb" "github.com/mvt-project/androidqf/log" @@ -20,6 +21,12 @@ import ( "github.com/mvt-project/androidqf/utils" ) +type deviceMenuItem struct { + Serial string + Title string + Status string +} + func init() { cfmt.Print(` {{ __ _ __ ____ }}::green @@ -38,6 +45,79 @@ func systemPause() { os.Stdin.Read(make([]byte, 1)) } +func buildDeviceMenuItems(devices []adb.DeviceInfo, running map[string]runningExtraction) []deviceMenuItem { + items := make([]deviceMenuItem, 0, len(devices)) + for _, device := range devices { + item := deviceMenuItem{ + Serial: device.Serial, + Title: deviceMenuTitle(device), + Status: deviceMenuStatus(device), + } + if state, ok := running[device.Serial]; ok { + if item.Status != "" { + item.Status += " " + } + item.Status += fmt.Sprintf("(extraction running, pid %d, started %s)", state.PID, state.Started.Local().Format("2006-01-02 15:04:05")) + } + items = append(items, item) + } + return items +} + +func deviceMenuTitle(device adb.DeviceInfo) string { + name := device.Model + if name == "" { + name = device.Device + } + if name == "" { + name = device.Product + } + name = strings.ReplaceAll(name, "_", " ") + if name == "" { + return device.Serial + } + return fmt.Sprintf("%s (%s)", name, device.Serial) +} + +func deviceMenuStatus(device adb.DeviceInfo) string { + if device.State == "" || device.State == "device" { + return "" + } + return fmt.Sprintf("(%s)", device.State) +} + +func selectADBDeviceFromMenu(items []deviceMenuItem) (string, error) { + promptDevice := promptui.Select{ + Label: "Multiple Android devices detected. Select the device to acquire", + Items: items, + Templates: &promptui.SelectTemplates{ + Active: "> {{ .Title | cyan }} {{ .Status | yellow }}", + Inactive: " {{ .Title }} {{ .Status }}", + Selected: "{{ .Title }}", + }, + } + + index, _, err := promptDevice.Run() + if err != nil { + return "", fmt.Errorf("failed to select ADB device: %v", err) + } + + return items[index].Serial, nil +} + +func resolveADBSerial(serial string, devices []adb.DeviceInfo, selectDevice func([]deviceMenuItem) (string, error), running map[string]runningExtraction) (string, bool, error) { + serial = strings.TrimSpace(serial) + if serial != "" || len(devices) == 0 { + return serial, false, nil + } + if len(devices) == 1 { + return devices[0].Serial, false, nil + } + + selectedSerial, err := selectDevice(buildDeviceMenuItems(devices, running)) + return selectedSerial, true, err +} + func main() { var err error var verbose bool @@ -104,12 +184,30 @@ func main() { } } } + specificDeviceRequested := serial != "" // Initialization for { + if serial == "" { + devices, err := adb.Client.DeviceInfos() + if err != nil { + log.Error(fmt.Sprintf("Error listing ADB devices: %s", err)) + } else { + serial, _, err = resolveADBSerial(serial, devices, selectADBDeviceFromMenu, activeRunningExtractionsBySerial()) + if err != nil { + log.Error(fmt.Sprintf("Error selecting ADB device: %s", err)) + time.Sleep(5 * time.Second) + continue + } + } + } + serial, err = adb.Client.SetSerial(serial) if err != nil { log.Error(fmt.Sprintf("Error trying to connect over ADB: %s", err)) + if !specificDeviceRequested { + serial = "" + } } else { _, err = adb.Client.GetState() if err == nil { @@ -117,10 +215,25 @@ func main() { } log.Debug(err) log.Error("Unable to get device state. Please make sure it is connected and authorized. Trying again in 5 seconds...") + if !specificDeviceRequested { + serial = "" + } } time.Sleep(5 * time.Second) } + releaseRunning, err := registerRunningExtraction(adb.Client.Serial, "") + if err != nil { + log.Warningf("Unable to record running extraction state: %v", err) + releaseRunning = func() {} + } + runningReleased := false + defer func() { + if !runningReleased { + releaseRunning() + } + }() + acq, err := acquisition.New(output_folder) if err != nil { log.Debug(err) @@ -176,6 +289,8 @@ func main() { } acq.Complete() + releaseRunning() + runningReleased = true log.Info("Acquisition completed.") systemPause() diff --git a/run_state.go b/run_state.go new file mode 100644 index 0000000..bd424c6 --- /dev/null +++ b/run_state.go @@ -0,0 +1,131 @@ +// androidqf - Android Quick Forensics +// Copyright (c) 2021-2023 Claudio Guarnieri. +// Use of this software is governed by the MVT License 1.1 that can be found at +// https://license.mvt.re/1.1/ + +package main + +import ( + "encoding/base64" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strconv" + "strings" + "time" +) + +type runningExtraction struct { + Serial string `json:"serial"` + PID int `json:"pid"` + Started time.Time `json:"started"` + StoragePath string `json:"storage_path,omitempty"` +} + +var ( + runningStateDir = defaultRunningStateDir + processExists = defaultProcessExists +) + +func defaultRunningStateDir() string { + cacheDir, err := os.UserCacheDir() + if err != nil { + return filepath.Join(os.TempDir(), "androidqf", "running") + } + return filepath.Join(cacheDir, "androidqf", "running") +} + +func runningExtractionFileName(pid int, serial string) string { + encodedSerial := base64.RawURLEncoding.EncodeToString([]byte(serial)) + return fmt.Sprintf("%d-%s.json", pid, encodedSerial) +} + +func registerRunningExtraction(serial, storagePath string) (func(), error) { + if strings.TrimSpace(serial) == "" { + return func() {}, nil + } + + stateDir := runningStateDir() + if err := os.MkdirAll(stateDir, 0o755); err != nil { + return nil, err + } + + state := runningExtraction{ + Serial: serial, + PID: os.Getpid(), + Started: time.Now().UTC(), + StoragePath: storagePath, + } + statePath := filepath.Join(stateDir, runningExtractionFileName(state.PID, state.Serial)) + + data, err := json.MarshalIndent(state, "", " ") + if err != nil { + return nil, err + } + if err := os.WriteFile(statePath, data, 0o644); err != nil { + return nil, err + } + + return func() { + _ = os.Remove(statePath) + }, nil +} + +func activeRunningExtractionsBySerial() map[string]runningExtraction { + result := make(map[string]runningExtraction) + stateDir := runningStateDir() + + entries, err := os.ReadDir(stateDir) + if err != nil { + return result + } + + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".json") { + continue + } + + statePath := filepath.Join(stateDir, entry.Name()) + data, err := os.ReadFile(statePath) + if err != nil { + continue + } + + var state runningExtraction + if err := json.Unmarshal(data, &state); err != nil || state.Serial == "" || state.PID == 0 { + _ = os.Remove(statePath) + continue + } + + if !processExists(state.PID) { + _ = os.Remove(statePath) + continue + } + + result[state.Serial] = state + } + + return result +} + +func defaultProcessExists(pid int) bool { + if pid <= 0 { + return false + } + if pid == os.Getpid() { + return true + } + + if runtime.GOOS == "windows" { + out, err := exec.Command("tasklist", "/FI", fmt.Sprintf("PID eq %d", pid), "/FO", "CSV", "/NH").Output() + if err != nil { + return false + } + return strings.Contains(string(out), fmt.Sprintf("\"%d\"", pid)) + } + + return exec.Command("kill", "-0", strconv.Itoa(pid)).Run() == nil +} diff --git a/run_state_test.go b/run_state_test.go new file mode 100644 index 0000000..a27b09b --- /dev/null +++ b/run_state_test.go @@ -0,0 +1,69 @@ +package main + +import ( + "os" + "testing" +) + +func TestRegisterRunningExtractionAppearsActiveAndReleases(t *testing.T) { + stateDir := t.TempDir() + oldStateDir := runningStateDir + oldProcessExists := processExists + runningStateDir = func() string { return stateDir } + processExists = func(pid int) bool { return pid == os.Getpid() } + t.Cleanup(func() { + runningStateDir = oldStateDir + processExists = oldProcessExists + }) + + release, err := registerRunningExtraction("device-1", "out") + if err != nil { + t.Fatalf("registerRunningExtraction returned error: %v", err) + } + + active := activeRunningExtractionsBySerial() + state, ok := active["device-1"] + if !ok { + t.Fatal("device-1 was not found in active running extractions") + } + if state.StoragePath != "out" { + t.Fatalf("storage path = %q, want out", state.StoragePath) + } + + release() + active = activeRunningExtractionsBySerial() + if _, ok := active["device-1"]; ok { + t.Fatal("device-1 remained active after release") + } +} + +func TestActiveRunningExtractionsRemovesStaleState(t *testing.T) { + stateDir := t.TempDir() + oldStateDir := runningStateDir + oldProcessExists := processExists + runningStateDir = func() string { return stateDir } + processExists = func(int) bool { return false } + t.Cleanup(func() { + runningStateDir = oldStateDir + processExists = oldProcessExists + }) + + release, err := registerRunningExtraction("device-1", "out") + if err != nil { + t.Fatalf("registerRunningExtraction returned error: %v", err) + } + defer release() + + active := activeRunningExtractionsBySerial() + if len(active) != 0 { + t.Fatalf("active state count = %d, want 0", len(active)) + } + + entries, err := os.ReadDir(stateDir) + if err != nil { + t.Fatalf("ReadDir returned error: %v", err) + } + if len(entries) != 0 { + t.Fatalf("state files remaining = %d, want 0", len(entries)) + } +}