Skip to content

chore: add agent endpoint for querying file system #16736

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Mar 7, 2025
Merged
1 change: 1 addition & 0 deletions agent/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
181 changes: 181 additions & 0 deletions agent/ls.go
Original file line number Diff line number Diff line change
@@ -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"
)
207 changes: 207 additions & 0 deletions agent/ls_internal_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
Loading
Loading