diff --git a/internal/files/files.go b/internal/files/files.go index 97e218f..8aa0206 100644 --- a/internal/files/files.go +++ b/internal/files/files.go @@ -1,6 +1,7 @@ package files import ( + "bufio" "bytes" "encoding/json" "errors" @@ -9,6 +10,7 @@ import ( "net/http" "os" "path/filepath" + "slices" "strconv" "strings" @@ -141,9 +143,11 @@ func Upload(invocation api.Invocation, arguments []string) error { defer response.Body.Close() fileNoun := "files" + arrival := "They arrive" if len(collected) == 1 { fileNoun = "file" + arrival = "It arrives" } if isImei { @@ -151,8 +155,8 @@ func Upload(invocation api.Invocation, arguments []string) error { return api.ServerError(response) } - fmt.Fprintf(invocation.Out, "Uploaded %d %s to device %s. They arrive at its next check-in.\n", - len(collected), fileNoun, target) + fmt.Fprintf(invocation.Out, "Uploaded %d %s to device %s. %s at its next check-in.\n", + len(collected), fileNoun, target, arrival) return nil } @@ -177,8 +181,141 @@ func Upload(invocation api.Invocation, arguments []string) error { deviceNoun = "device" } - fmt.Fprintf(invocation.Out, "Uploaded %d %s to %d %s in fleet %d. They arrive at each device's next check-in.\n", - len(collected), fileNoun, result.Devices, deviceNoun, fleetID) + fmt.Fprintf(invocation.Out, "Uploaded %d %s to %d %s in fleet %d. %s at each device's next check-in.\n", + len(collected), fileNoun, result.Devices, deviceNoun, fleetID, arrival) + + return nil +} + +func Download(invocation api.Invocation, arguments []string) error { + if len(arguments) != 2 { + return errors.New("download takes an IMEI, then the directory to download into") + } + + imei := arguments[0] + isImei := len(imei) == 15 && !strings.ContainsFunc(imei, func(digit rune) bool { return digit < '0' || digit > '9' }) + + if !isImei { + return errors.New("download names one device by the 15-digit IMEI printed on it, not a fleet") + } + + destination := arguments[1] + info, err := os.Stat(destination) + + if err != nil && !errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s could not be read", destination) + } + + if err == nil && !info.IsDir() { + return fmt.Errorf("%s is a file, choose a directory", destination) + } + + request, err := api.AuthenticatedRequest(invocation, http.MethodGet, "/devices/"+imei+"/files", nil) + + if err != nil { + return err + } + + response, err := invocation.Client.Do(request) + + if err != nil { + return errors.New("the server could not be reached, check your internet access") + } + + defer response.Body.Close() + + if response.StatusCode != http.StatusOK { + return api.ServerError(response) + } + + result := struct { + Files map[string][]byte `json:"files"` + }{} + + err = api.Decode(response, &result) + + if err != nil { + return err + } + + paths := make([]string, 0, len(result.Files)) + + for path := range result.Files { + if path == "" || strings.HasPrefix(path, "/") || filepath.IsAbs(filepath.FromSlash(path)) || slices.Contains(strings.Split(path, "/"), "..") { + return errors.New("the download could not be trusted, so nothing was written") + } + + paths = append(paths, path) + } + + slices.Sort(paths) + + root := filepath.Clean(destination) + + for _, path := range paths { + localPath := filepath.Join(root, filepath.FromSlash(path)) + + for parent := filepath.Dir(localPath); parent != root; parent = filepath.Dir(parent) { + info, err := os.Stat(parent) + + if err == nil && !info.IsDir() { + return fmt.Errorf("%s is a file where the code needs a directory", parent) + } + } + } + + existing := []string{} + + for _, path := range paths { + localPath := filepath.Join(destination, filepath.FromSlash(path)) + _, err := os.Stat(localPath) + + if err == nil { + existing = append(existing, localPath) + } + } + + if len(existing) == 1 { + fmt.Fprintf(invocation.Out, "%s already exists. Replace it? [y/N] ", existing[0]) + } else if len(existing) > 1 { + fmt.Fprintf(invocation.Out, "These files already exist:\n%s\nReplace them? [y/N] ", strings.Join(existing, "\n")) + } + + if len(existing) > 0 { + answer, _ := bufio.NewReader(invocation.In).ReadString('\n') + + answer = strings.ToLower(strings.TrimSpace(answer)) + + if answer != "y" && answer != "yes" { + fmt.Fprintln(invocation.Out, "Nothing downloaded.") + return nil + } + } + + for _, path := range paths { + localPath := filepath.Join(destination, filepath.FromSlash(path)) + err = os.MkdirAll(filepath.Dir(localPath), 0o755) + + if err != nil { + return fmt.Errorf("%s could not be created", filepath.Dir(localPath)) + } + + err = os.WriteFile(localPath, result.Files[path], 0o644) + + if err != nil { + return fmt.Errorf("%s could not be written", localPath) + } + + fmt.Fprintln(invocation.Out, localPath) + } + + fileNoun := "files" + + if len(paths) == 1 { + fileNoun = "file" + } + + fmt.Fprintf(invocation.Out, "Downloaded %d %s from device %s into %s.\n", len(paths), fileNoun, imei, destination) return nil } diff --git a/internal/files/files_test.go b/internal/files/files_test.go index a5bf41c..28a4778 100644 --- a/internal/files/files_test.go +++ b/internal/files/files_test.go @@ -2,6 +2,7 @@ package files import ( "encoding/json" + "io/fs" "net/http" "os" "path/filepath" @@ -71,7 +72,7 @@ func TestUpload(t *testing.T) { arguments: []string{"3", filepath.Join(project, "main.lua")}, wantPath: "/fleets/3/files", wantFiles: map[string]string{"main.lua": "print(1)"}, - wantOutput: "Uploaded 1 file to 2 devices in fleet 3. They arrive at each device's next check-in.\n", + wantOutput: "Uploaded 1 file to 2 devices in fleet 3. It arrives at each device's next check-in.\n", }, } @@ -173,3 +174,211 @@ func TestUploadArgumentsAndRefusal(t *testing.T) { t.Errorf("output = %q, want nothing", out.String()) } } + +func TestDownload(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("GET /devices/354820091234567/files", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"files":{"main.lua":"cHJpbnQoMSk=","lib/sensor.lua":"cmV0dXJuIDI="}}`)) + }) + mux.HandleFunc("GET /devices/354820099999999/files", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no such device", http.StatusNotFound) + }) + mux.HandleFunc("GET /devices/354820098888888/files", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no such device", http.StatusNotFound) + }) + mux.HandleFunc("GET /devices/354820097777777/files", func(w http.ResponseWriter, r *http.Request) { + http.Error(w, "no code has been uploaded to this device", http.StatusNotFound) + }) + mux.HandleFunc("GET /devices/354820096666666/files", func(w http.ResponseWriter, r *http.Request) { + w.Write([]byte(`{"files":{"main.lua":"cHJpbnQoMSk=","../escape.lua":"cHJpbnQoMSk="}}`)) + }) + + directoryWith := func(files map[string]string) string { + directory := t.TempDir() + + for name, content := range files { + path := filepath.Join(directory, filepath.FromSlash(name)) + err := os.MkdirAll(filepath.Dir(path), 0o755) + + if err != nil { + t.Fatal(err) + } + + err = os.WriteFile(path, []byte(content), 0o644) + + if err != nil { + t.Fatal(err) + } + } + + return directory + } + + downloaded := func(destination string) string { + return filepath.Join(destination, "lib", "sensor.lua") + "\n" + filepath.Join(destination, "main.lua") + "\n" + + "Downloaded 2 files from device 354820091234567 into " + destination + ".\n" + } + + bundle := map[string]string{"lib/sensor.lua": "return 2", "main.lua": "print(1)"} + fresh := filepath.Join(t.TempDir(), "new") + empty := t.TempDir() + unrelated := directoryWith(map[string]string{"notes.txt": "keep"}) + file := filepath.Join(unrelated, "notes.txt") + replaced := directoryWith(map[string]string{"main.lua": "print(0)"}) + declined := directoryWith(map[string]string{"main.lua": "print(0)"}) + unanswered := directoryWith(map[string]string{"main.lua": "print(0)"}) + both := directoryWith(map[string]string{"lib/sensor.lua": "return 0", "main.lua": "print(0)", "notes.txt": "keep"}) + blocked := directoryWith(map[string]string{"lib": "not a directory"}) + + tests := []struct { + name string + imei string + destination string + answer string + wantFiles map[string]string + wantOutput string + wantError string + }{ + { + name: "a new directory", + imei: "354820091234567", + destination: fresh, + wantFiles: bundle, + wantOutput: downloaded(fresh), + }, + { + name: "an empty directory", + imei: "354820091234567", + destination: empty, + wantFiles: bundle, + wantOutput: downloaded(empty), + }, + { + name: "a directory with unrelated files", + imei: "354820091234567", + destination: unrelated, + wantFiles: map[string]string{"lib/sensor.lua": "return 2", "main.lua": "print(1)", "notes.txt": "keep"}, + wantOutput: downloaded(unrelated), + }, + { + name: "a conflicting file, replaced", + imei: "354820091234567", + destination: replaced, + answer: "y\n", + wantFiles: bundle, + wantOutput: filepath.Join(replaced, "main.lua") + " already exists. Replace it? [y/N] " + downloaded(replaced), + }, + { + name: "a conflicting file, declined", + imei: "354820091234567", + destination: declined, + answer: "n\n", + wantFiles: map[string]string{"main.lua": "print(0)"}, + wantOutput: filepath.Join(declined, "main.lua") + " already exists. Replace it? [y/N] Nothing downloaded.\n", + }, + { + name: "a conflicting file, answered with an empty line", + imei: "354820091234567", + destination: unanswered, + answer: "\n", + wantFiles: map[string]string{"main.lua": "print(0)"}, + wantOutput: filepath.Join(unanswered, "main.lua") + " already exists. Replace it? [y/N] Nothing downloaded.\n", + }, + { + name: "several conflicting files, replaced", + imei: "354820091234567", + destination: both, + answer: "yes\n", + wantFiles: map[string]string{"lib/sensor.lua": "return 2", "main.lua": "print(1)", "notes.txt": "keep"}, + wantOutput: "These files already exist:\n" + filepath.Join(both, "lib", "sensor.lua") + "\n" + filepath.Join(both, "main.lua") + + "\nReplace them? [y/N] " + downloaded(both), + }, + {"an unknown device", "354820099999999", t.TempDir(), "", nil, "", "no such device"}, + {"a device in a fleet the user is not a member of", "354820098888888", t.TempDir(), "", nil, "", "no such device"}, + {"a device with no uploaded code", "354820097777777", t.TempDir(), "", nil, "", "no code has been uploaded to this device"}, + {"a fleet id as the target", "3", t.TempDir(), "", nil, "", "15-digit IMEI"}, + {"a wordy target", "rooftop", t.TempDir(), "", nil, "", "15-digit IMEI"}, + {"a file as the target", "354820091234567", file, "", nil, "", "is a file, choose a directory"}, + { + name: "a file where the bundle has a directory", + imei: "354820091234567", + destination: blocked, + wantFiles: map[string]string{"lib": "not a directory"}, + wantError: filepath.Join(blocked, "lib") + " is a file where the code needs a directory", + }, + {"a bundle path that escapes the directory", "354820096666666", t.TempDir(), "", nil, "", "could not be trusted"}, + {"a missing argument", "354820091234567", "", "", nil, "", "takes an IMEI"}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + invocation, out := apitest.LoggedInInvocation(t, mux) + invocation.In = strings.NewReader(test.answer) + arguments := []string{test.imei, test.destination} + + if test.destination == "" { + arguments = arguments[:1] + } + + err := Download(invocation, arguments) + + if test.wantError != "" { + if err == nil || !strings.Contains(err.Error(), test.wantError) { + t.Errorf("error = %v, want it to mention %q", err, test.wantError) + } + + if out.String() != "" { + t.Errorf("output = %q, want nothing", out.String()) + } + + if test.wantFiles == nil { + entries, _ := os.ReadDir(test.destination) + + if len(entries) > 0 { + t.Errorf("%d files were written, want none", len(entries)) + } + + return + } + } else if err != nil { + t.Fatal(err) + } + + gotFiles := map[string]string{} + + err = filepath.WalkDir(test.destination, func(path string, entry fs.DirEntry, walkError error) error { + if walkError != nil || entry.IsDir() { + return walkError + } + + content, err := os.ReadFile(path) + + if err != nil { + return err + } + + relative, err := filepath.Rel(test.destination, path) + + if err != nil { + return err + } + + gotFiles[filepath.ToSlash(relative)] = string(content) + + return nil + }) + + if err != nil { + t.Fatal(err) + } + + if !reflect.DeepEqual(gotFiles, test.wantFiles) { + t.Errorf("files in %s = %v, want %v", test.destination, gotFiles, test.wantFiles) + } + + if out.String() != test.wantOutput { + t.Errorf("output = %q, want %q", out.String(), test.wantOutput) + } + }) + } +} diff --git a/main.go b/main.go index 35e9104..fcef161 100644 --- a/main.go +++ b/main.go @@ -50,7 +50,7 @@ var sections = []dispatch.Section{ Title: "Files", Commands: []dispatch.Command{ {Name: "upload", Arguments: " ...", Summary: "Upload files or directories to a device or fleet", Run: files.Upload}, - {Name: "download", Arguments: " ", Summary: "Download a device or fleet's files into "}, + {Name: "download", Arguments: " ", Summary: "Download the code last uploaded to a device into ", Run: files.Download}, {Name: "dev", Arguments: " ... [--log-file ]", Summary: "Upload on every change, and tail"}, }, }, diff --git a/main_test.go b/main_test.go index da56442..9f8a8e4 100644 --- a/main_test.go +++ b/main_test.go @@ -46,7 +46,6 @@ func TestOnlyPlannedCommandsAreUnimplemented(t *testing.T) { "device start": true, "device stop": true, "device restart": true, - "download": true, "dev": true, "tail": true, } @@ -162,6 +161,7 @@ func TestTheTableWiresEveryCommandOffered(t *testing.T) { wired := []string{ "account balance", "account delete", "account topup", "device list", "device pair", "device rename", "device unpair", + "download", "fleet create", "fleet delete", "fleet list", "fleet rename", "fleet transfer", "key create", "key list", "key revoke", "login", "logout",