diff --git a/agent/api.go b/agent/api.go index a3241feb3b7ee..259866797a3c4 100644 --- a/agent/api.go +++ b/agent/api.go @@ -41,6 +41,7 @@ func (a *agent) apiHandler() http.Handler { r.Get("/api/v0/containers", ch.ServeHTTP) r.Get("/api/v0/listening-ports", lp.handler) r.Get("/api/v0/netcheck", a.HandleNetcheck) + r.Post("/api/v0/list-directory", a.HandleLS) r.Get("/debug/logs", a.HandleHTTPDebugLogs) r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock) r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState) diff --git a/agent/ls.go b/agent/ls.go new file mode 100644 index 0000000000000..1d8adea12e0b4 --- /dev/null +++ b/agent/ls.go @@ -0,0 +1,181 @@ +package agent + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "regexp" + "runtime" + "strings" + + "github.com/shirou/gopsutil/v4/disk" + "golang.org/x/xerrors" + + "github.com/coder/coder/v2/coderd/httpapi" + "github.com/coder/coder/v2/codersdk" +) + +var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`) + +func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + var query LSRequest + if !httpapi.Read(ctx, rw, r, &query) { + return + } + + resp, err := listFiles(query) + if err != nil { + status := http.StatusInternalServerError + switch { + case errors.Is(err, os.ErrNotExist): + status = http.StatusNotFound + case errors.Is(err, os.ErrPermission): + status = http.StatusForbidden + default: + } + httpapi.Write(ctx, rw, status, codersdk.Response{ + Message: err.Error(), + }) + return + } + + httpapi.Write(ctx, rw, http.StatusOK, resp) +} + +func listFiles(query LSRequest) (LSResponse, error) { + var fullPath []string + switch query.Relativity { + case LSRelativityHome: + home, err := os.UserHomeDir() + if err != nil { + return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err) + } + fullPath = []string{home} + case LSRelativityRoot: + if runtime.GOOS == "windows" { + if len(query.Path) == 0 { + return listDrives() + } + if !WindowsDriveRegex.MatchString(query.Path[0]) { + return LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0]) + } + } else { + fullPath = []string{"/"} + } + default: + return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity) + } + + fullPath = append(fullPath, query.Path...) + fullPathRelative := filepath.Join(fullPath...) + absolutePathString, err := filepath.Abs(fullPathRelative) + if err != nil { + return LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err) + } + + f, err := os.Open(absolutePathString) + if err != nil { + return LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err) + } + defer f.Close() + + stat, err := f.Stat() + if err != nil { + return LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err) + } + + if !stat.IsDir() { + return LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString) + } + + // `contents` may be partially populated even if the operation fails midway. + contents, _ := f.ReadDir(-1) + respContents := make([]LSFile, 0, len(contents)) + for _, file := range contents { + respContents = append(respContents, LSFile{ + Name: file.Name(), + AbsolutePathString: filepath.Join(absolutePathString, file.Name()), + IsDir: file.IsDir(), + }) + } + + absolutePath := pathToArray(absolutePathString) + + return LSResponse{ + AbsolutePath: absolutePath, + AbsolutePathString: absolutePathString, + Contents: respContents, + }, nil +} + +func listDrives() (LSResponse, error) { + partitionStats, err := disk.Partitions(true) + if err != nil { + return LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err) + } + contents := make([]LSFile, 0, len(partitionStats)) + for _, a := range partitionStats { + // Drive letters on Windows have a trailing separator as part of their name. + // i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does. + name := a.Mountpoint + string(os.PathSeparator) + contents = append(contents, LSFile{ + Name: name, + AbsolutePathString: name, + IsDir: true, + }) + } + + return LSResponse{ + AbsolutePath: []string{}, + AbsolutePathString: "", + Contents: contents, + }, nil +} + +func pathToArray(path string) []string { + out := strings.FieldsFunc(path, func(r rune) bool { + return r == os.PathSeparator + }) + // Drive letters on Windows have a trailing separator as part of their name. + // i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does. + if runtime.GOOS == "windows" && len(out) > 0 { + out[0] += string(os.PathSeparator) + } + return out +} + +type LSRequest struct { + // e.g. [], ["repos", "coder"], + Path []string `json:"path"` + // Whether the supplied path is relative to the user's home directory, + // or the root directory. + Relativity LSRelativity `json:"relativity"` +} + +type LSResponse struct { + AbsolutePath []string `json:"absolute_path"` + // Returned so clients can display the full path to the user, and + // copy it to configure file sync + // e.g. Windows: "C:\\Users\\coder" + // Linux: "/home/coder" + AbsolutePathString string `json:"absolute_path_string"` + Contents []LSFile `json:"contents"` +} + +type LSFile struct { + Name string `json:"name"` + // e.g. "C:\\Users\\coder\\hello.txt" + // "/home/coder/hello.txt" + AbsolutePathString string `json:"absolute_path_string"` + IsDir bool `json:"is_dir"` +} + +type LSRelativity string + +const ( + LSRelativityRoot LSRelativity = "root" + LSRelativityHome LSRelativity = "home" +) diff --git a/agent/ls_internal_test.go b/agent/ls_internal_test.go new file mode 100644 index 0000000000000..acc4ea2929444 --- /dev/null +++ b/agent/ls_internal_test.go @@ -0,0 +1,207 @@ +package agent + +import ( + "os" + "path/filepath" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListFilesNonExistentDirectory(t *testing.T) { + t.Parallel() + + query := LSRequest{ + Path: []string{"idontexist"}, + Relativity: LSRelativityHome, + } + _, err := listFiles(query) + require.ErrorIs(t, err, os.ErrNotExist) +} + +func TestListFilesPermissionDenied(t *testing.T) { + t.Parallel() + + if runtime.GOOS == "windows" { + t.Skip("creating an unreadable-by-user directory is non-trivial on Windows") + } + + home, err := os.UserHomeDir() + require.NoError(t, err) + + tmpDir := t.TempDir() + + reposDir := filepath.Join(tmpDir, "repos") + err = os.Mkdir(reposDir, 0o000) + require.NoError(t, err) + + rel, err := filepath.Rel(home, reposDir) + require.NoError(t, err) + + query := LSRequest{ + Path: pathToArray(rel), + Relativity: LSRelativityHome, + } + _, err = listFiles(query) + require.ErrorIs(t, err, os.ErrPermission) +} + +func TestListFilesNotADirectory(t *testing.T) { + t.Parallel() + + home, err := os.UserHomeDir() + require.NoError(t, err) + + tmpDir := t.TempDir() + + filePath := filepath.Join(tmpDir, "file.txt") + err = os.WriteFile(filePath, []byte("content"), 0o600) + require.NoError(t, err) + + rel, err := filepath.Rel(home, filePath) + require.NoError(t, err) + + query := LSRequest{ + Path: pathToArray(rel), + Relativity: LSRelativityHome, + } + _, err = listFiles(query) + require.ErrorContains(t, err, "is not a directory") +} + +func TestListFilesSuccess(t *testing.T) { + t.Parallel() + + tc := []struct { + name string + baseFunc func(t *testing.T) string + relativity LSRelativity + }{ + { + name: "home", + baseFunc: func(t *testing.T) string { + home, err := os.UserHomeDir() + require.NoError(t, err) + return home + }, + relativity: LSRelativityHome, + }, + { + name: "root", + baseFunc: func(*testing.T) string { + if runtime.GOOS == "windows" { + return "" + } + return "/" + }, + relativity: LSRelativityRoot, + }, + } + + // nolint:paralleltest // Not since Go v1.22. + for _, tc := range tc { + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + base := tc.baseFunc(t) + tmpDir := t.TempDir() + + reposDir := filepath.Join(tmpDir, "repos") + err := os.Mkdir(reposDir, 0o755) + require.NoError(t, err) + + downloadsDir := filepath.Join(tmpDir, "Downloads") + err = os.Mkdir(downloadsDir, 0o755) + require.NoError(t, err) + + textFile := filepath.Join(tmpDir, "file.txt") + err = os.WriteFile(textFile, []byte("content"), 0o600) + require.NoError(t, err) + + var queryComponents []string + // We can't get an absolute path relative to empty string on Windows. + if runtime.GOOS == "windows" && base == "" { + queryComponents = pathToArray(tmpDir) + } else { + rel, err := filepath.Rel(base, tmpDir) + require.NoError(t, err) + queryComponents = pathToArray(rel) + } + + query := LSRequest{ + Path: queryComponents, + Relativity: tc.relativity, + } + resp, err := listFiles(query) + require.NoError(t, err) + + require.Equal(t, tmpDir, resp.AbsolutePathString) + require.ElementsMatch(t, []LSFile{ + { + Name: "repos", + AbsolutePathString: reposDir, + IsDir: true, + }, + { + Name: "Downloads", + AbsolutePathString: downloadsDir, + IsDir: true, + }, + { + Name: "file.txt", + AbsolutePathString: textFile, + IsDir: false, + }, + }, resp.Contents) + }) + } +} + +func TestListFilesListDrives(t *testing.T) { + t.Parallel() + + if runtime.GOOS != "windows" { + t.Skip("skipping test on non-Windows OS") + } + + query := LSRequest{ + Path: []string{}, + Relativity: LSRelativityRoot, + } + resp, err := listFiles(query) + require.NoError(t, err) + require.Contains(t, resp.Contents, LSFile{ + Name: "C:\\", + AbsolutePathString: "C:\\", + IsDir: true, + }) + + query = LSRequest{ + Path: []string{"C:\\"}, + Relativity: LSRelativityRoot, + } + resp, err = listFiles(query) + require.NoError(t, err) + + query = LSRequest{ + Path: resp.AbsolutePath, + Relativity: LSRelativityRoot, + } + resp, err = listFiles(query) + require.NoError(t, err) + // System directory should always exist + require.Contains(t, resp.Contents, LSFile{ + Name: "Windows", + AbsolutePathString: "C:\\Windows", + IsDir: true, + }) + + query = LSRequest{ + // Network drives are not supported. + Path: []string{"\\sshfs\\work"}, + Relativity: LSRelativityRoot, + } + resp, err = listFiles(query) + require.ErrorContains(t, err, "drive") +} diff --git a/go.mod b/go.mod index 4b38c65265f4d..1e68a84f47002 100644 --- a/go.mod +++ b/go.mod @@ -164,6 +164,7 @@ require ( github.com/prometheus/common v0.62.0 github.com/quasilyte/go-ruleguard/dsl v0.3.21 github.com/robfig/cron/v3 v3.0.1 + github.com/shirou/gopsutil/v4 v4.25.2 github.com/skratchdot/open-golang v0.0.0-20200116055534-eef842397966 github.com/spf13/afero v1.12.0 github.com/spf13/pflag v1.0.5 @@ -285,7 +286,7 @@ require ( github.com/dop251/goja v0.0.0-20241024094426-79f3a7efcdbd // indirect github.com/dustin/go-humanize v1.0.1 github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 // indirect - github.com/ebitengine/purego v0.6.0-alpha.5 // indirect + github.com/ebitengine/purego v0.8.2 // indirect github.com/elastic/go-windows v1.0.0 // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/felixge/httpsnoop v1.0.4 // indirect diff --git a/go.sum b/go.sum index 6496dfc84118d..bd29a7b7bef56 100644 --- a/go.sum +++ b/go.sum @@ -301,8 +301,8 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4 h1:8EXxF+tCLqaVk8AOC29zl2mnhQjwyLxxOTuhUazWRsg= github.com/eapache/queue/v2 v2.0.0-20230407133247-75960ed334e4/go.mod h1:I5sHm0Y0T1u5YjlyqC5GVArM7aNZRUYtTjmJ8mPJFds= -github.com/ebitengine/purego v0.6.0-alpha.5 h1:EYID3JOAdmQ4SNZYJHu9V6IqOeRQDBYxqKAg9PyoHFY= -github.com/ebitengine/purego v0.6.0-alpha.5/go.mod h1:ah1In8AOtksoNK6yk5z1HTJeUkC1Ez4Wk2idgGslMwQ= +github.com/ebitengine/purego v0.8.2 h1:jPPGWs2sZ1UgOSgD2bClL0MJIqu58nOmIcBuXr62z1I= +github.com/ebitengine/purego v0.8.2/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/elastic/go-sysinfo v1.15.0 h1:54pRFlAYUlVNQ2HbXzLVZlV+fxS7Eax49stzg95M4Xw= github.com/elastic/go-sysinfo v1.15.0/go.mod h1:jPSuTgXG+dhhh0GKIyI2Cso+w5lPJ5PvVqKlL8LV/Hk= github.com/elastic/go-windows v1.0.0 h1:qLURgZFkkrYyTTkvYpsZIgf83AUsdIHfvlJaqaZ7aSY= @@ -825,6 +825,8 @@ github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3 h1:n661drycOFuPLCN github.com/sergi/go-diff v1.3.2-0.20230802210424-5b0b94c5c0d3/go.mod h1:A0bzQcvG0E7Rwjx0REVgAGH58e96+X0MeOfepqsbeW4= github.com/shirou/gopsutil/v3 v3.24.4 h1:dEHgzZXt4LMNm+oYELpzl9YCqV65Yr/6SfrvgRBtXeU= github.com/shirou/gopsutil/v3 v3.24.4/go.mod h1:lTd2mdiOspcqLgAnr9/nGi71NkeMpWKdmhuxm9GusH8= +github.com/shirou/gopsutil/v4 v4.25.2 h1:NMscG3l2CqtWFS86kj3vP7soOczqrQYIEhO/pMvvQkk= +github.com/shirou/gopsutil/v4 v4.25.2/go.mod h1:34gBYJzyqCDT11b6bMHP0XCvWeU3J61XRT7a2EmCRTA= github.com/shoenig/go-m1cpu v0.1.6 h1:nxdKQNcEB6vzgA2E2bvzKIYRuNj7XNJ4S/aRSwKzFtM= github.com/shoenig/go-m1cpu v0.1.6/go.mod h1:1JJMcUBvfNwpq05QDQVAnx3gUHr9IYF7GNg9SUEw2VQ= github.com/shoenig/test v0.6.4 h1:kVTaSd7WLz5WZ2IaoM0RSzRsUD+m8wRR+5qvntpn4LU=