Skip to content

Commit 7cf9580

Browse files
ethanndicksonaslilac
authored andcommitted
chore: add agent endpoint for querying file system (#16736)
Closes coder/internal#382
1 parent 66c3ff5 commit 7cf9580

File tree

5 files changed

+395
-3
lines changed

5 files changed

+395
-3
lines changed

agent/api.go

+1
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,7 @@ func (a *agent) apiHandler() http.Handler {
4141
r.Get("/api/v0/containers", ch.ServeHTTP)
4242
r.Get("/api/v0/listening-ports", lp.handler)
4343
r.Get("/api/v0/netcheck", a.HandleNetcheck)
44+
r.Post("/api/v0/list-directory", a.HandleLS)
4445
r.Get("/debug/logs", a.HandleHTTPDebugLogs)
4546
r.Get("/debug/magicsock", a.HandleHTTPDebugMagicsock)
4647
r.Get("/debug/magicsock/debug-logging/{state}", a.HandleHTTPMagicsockDebugLoggingState)

agent/ls.go

+181
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,181 @@
1+
package agent
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"regexp"
9+
"runtime"
10+
"strings"
11+
12+
"github.com/shirou/gopsutil/v4/disk"
13+
"golang.org/x/xerrors"
14+
15+
"github.com/coder/coder/v2/coderd/httpapi"
16+
"github.com/coder/coder/v2/codersdk"
17+
)
18+
19+
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
20+
21+
func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
22+
ctx := r.Context()
23+
24+
var query LSRequest
25+
if !httpapi.Read(ctx, rw, r, &query) {
26+
return
27+
}
28+
29+
resp, err := listFiles(query)
30+
if err != nil {
31+
status := http.StatusInternalServerError
32+
switch {
33+
case errors.Is(err, os.ErrNotExist):
34+
status = http.StatusNotFound
35+
case errors.Is(err, os.ErrPermission):
36+
status = http.StatusForbidden
37+
default:
38+
}
39+
httpapi.Write(ctx, rw, status, codersdk.Response{
40+
Message: err.Error(),
41+
})
42+
return
43+
}
44+
45+
httpapi.Write(ctx, rw, http.StatusOK, resp)
46+
}
47+
48+
func listFiles(query LSRequest) (LSResponse, error) {
49+
var fullPath []string
50+
switch query.Relativity {
51+
case LSRelativityHome:
52+
home, err := os.UserHomeDir()
53+
if err != nil {
54+
return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
55+
}
56+
fullPath = []string{home}
57+
case LSRelativityRoot:
58+
if runtime.GOOS == "windows" {
59+
if len(query.Path) == 0 {
60+
return listDrives()
61+
}
62+
if !WindowsDriveRegex.MatchString(query.Path[0]) {
63+
return LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
64+
}
65+
} else {
66+
fullPath = []string{"/"}
67+
}
68+
default:
69+
return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
70+
}
71+
72+
fullPath = append(fullPath, query.Path...)
73+
fullPathRelative := filepath.Join(fullPath...)
74+
absolutePathString, err := filepath.Abs(fullPathRelative)
75+
if err != nil {
76+
return LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
77+
}
78+
79+
f, err := os.Open(absolutePathString)
80+
if err != nil {
81+
return LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
82+
}
83+
defer f.Close()
84+
85+
stat, err := f.Stat()
86+
if err != nil {
87+
return LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
88+
}
89+
90+
if !stat.IsDir() {
91+
return LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
92+
}
93+
94+
// `contents` may be partially populated even if the operation fails midway.
95+
contents, _ := f.ReadDir(-1)
96+
respContents := make([]LSFile, 0, len(contents))
97+
for _, file := range contents {
98+
respContents = append(respContents, LSFile{
99+
Name: file.Name(),
100+
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
101+
IsDir: file.IsDir(),
102+
})
103+
}
104+
105+
absolutePath := pathToArray(absolutePathString)
106+
107+
return LSResponse{
108+
AbsolutePath: absolutePath,
109+
AbsolutePathString: absolutePathString,
110+
Contents: respContents,
111+
}, nil
112+
}
113+
114+
func listDrives() (LSResponse, error) {
115+
partitionStats, err := disk.Partitions(true)
116+
if err != nil {
117+
return LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
118+
}
119+
contents := make([]LSFile, 0, len(partitionStats))
120+
for _, a := range partitionStats {
121+
// Drive letters on Windows have a trailing separator as part of their name.
122+
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
123+
name := a.Mountpoint + string(os.PathSeparator)
124+
contents = append(contents, LSFile{
125+
Name: name,
126+
AbsolutePathString: name,
127+
IsDir: true,
128+
})
129+
}
130+
131+
return LSResponse{
132+
AbsolutePath: []string{},
133+
AbsolutePathString: "",
134+
Contents: contents,
135+
}, nil
136+
}
137+
138+
func pathToArray(path string) []string {
139+
out := strings.FieldsFunc(path, func(r rune) bool {
140+
return r == os.PathSeparator
141+
})
142+
// Drive letters on Windows have a trailing separator as part of their name.
143+
// i.e. `os.Open("C:")` does not work, but `os.Open("C:\\")` does.
144+
if runtime.GOOS == "windows" && len(out) > 0 {
145+
out[0] += string(os.PathSeparator)
146+
}
147+
return out
148+
}
149+
150+
type LSRequest struct {
151+
// e.g. [], ["repos", "coder"],
152+
Path []string `json:"path"`
153+
// Whether the supplied path is relative to the user's home directory,
154+
// or the root directory.
155+
Relativity LSRelativity `json:"relativity"`
156+
}
157+
158+
type LSResponse struct {
159+
AbsolutePath []string `json:"absolute_path"`
160+
// Returned so clients can display the full path to the user, and
161+
// copy it to configure file sync
162+
// e.g. Windows: "C:\\Users\\coder"
163+
// Linux: "/home/coder"
164+
AbsolutePathString string `json:"absolute_path_string"`
165+
Contents []LSFile `json:"contents"`
166+
}
167+
168+
type LSFile struct {
169+
Name string `json:"name"`
170+
// e.g. "C:\\Users\\coder\\hello.txt"
171+
// "/home/coder/hello.txt"
172+
AbsolutePathString string `json:"absolute_path_string"`
173+
IsDir bool `json:"is_dir"`
174+
}
175+
176+
type LSRelativity string
177+
178+
const (
179+
LSRelativityRoot LSRelativity = "root"
180+
LSRelativityHome LSRelativity = "home"
181+
)

agent/ls_internal_test.go

+207
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,207 @@
1+
package agent
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"testing"
8+
9+
"github.com/stretchr/testify/require"
10+
)
11+
12+
func TestListFilesNonExistentDirectory(t *testing.T) {
13+
t.Parallel()
14+
15+
query := LSRequest{
16+
Path: []string{"idontexist"},
17+
Relativity: LSRelativityHome,
18+
}
19+
_, err := listFiles(query)
20+
require.ErrorIs(t, err, os.ErrNotExist)
21+
}
22+
23+
func TestListFilesPermissionDenied(t *testing.T) {
24+
t.Parallel()
25+
26+
if runtime.GOOS == "windows" {
27+
t.Skip("creating an unreadable-by-user directory is non-trivial on Windows")
28+
}
29+
30+
home, err := os.UserHomeDir()
31+
require.NoError(t, err)
32+
33+
tmpDir := t.TempDir()
34+
35+
reposDir := filepath.Join(tmpDir, "repos")
36+
err = os.Mkdir(reposDir, 0o000)
37+
require.NoError(t, err)
38+
39+
rel, err := filepath.Rel(home, reposDir)
40+
require.NoError(t, err)
41+
42+
query := LSRequest{
43+
Path: pathToArray(rel),
44+
Relativity: LSRelativityHome,
45+
}
46+
_, err = listFiles(query)
47+
require.ErrorIs(t, err, os.ErrPermission)
48+
}
49+
50+
func TestListFilesNotADirectory(t *testing.T) {
51+
t.Parallel()
52+
53+
home, err := os.UserHomeDir()
54+
require.NoError(t, err)
55+
56+
tmpDir := t.TempDir()
57+
58+
filePath := filepath.Join(tmpDir, "file.txt")
59+
err = os.WriteFile(filePath, []byte("content"), 0o600)
60+
require.NoError(t, err)
61+
62+
rel, err := filepath.Rel(home, filePath)
63+
require.NoError(t, err)
64+
65+
query := LSRequest{
66+
Path: pathToArray(rel),
67+
Relativity: LSRelativityHome,
68+
}
69+
_, err = listFiles(query)
70+
require.ErrorContains(t, err, "is not a directory")
71+
}
72+
73+
func TestListFilesSuccess(t *testing.T) {
74+
t.Parallel()
75+
76+
tc := []struct {
77+
name string
78+
baseFunc func(t *testing.T) string
79+
relativity LSRelativity
80+
}{
81+
{
82+
name: "home",
83+
baseFunc: func(t *testing.T) string {
84+
home, err := os.UserHomeDir()
85+
require.NoError(t, err)
86+
return home
87+
},
88+
relativity: LSRelativityHome,
89+
},
90+
{
91+
name: "root",
92+
baseFunc: func(*testing.T) string {
93+
if runtime.GOOS == "windows" {
94+
return ""
95+
}
96+
return "/"
97+
},
98+
relativity: LSRelativityRoot,
99+
},
100+
}
101+
102+
// nolint:paralleltest // Not since Go v1.22.
103+
for _, tc := range tc {
104+
t.Run(tc.name, func(t *testing.T) {
105+
t.Parallel()
106+
107+
base := tc.baseFunc(t)
108+
tmpDir := t.TempDir()
109+
110+
reposDir := filepath.Join(tmpDir, "repos")
111+
err := os.Mkdir(reposDir, 0o755)
112+
require.NoError(t, err)
113+
114+
downloadsDir := filepath.Join(tmpDir, "Downloads")
115+
err = os.Mkdir(downloadsDir, 0o755)
116+
require.NoError(t, err)
117+
118+
textFile := filepath.Join(tmpDir, "file.txt")
119+
err = os.WriteFile(textFile, []byte("content"), 0o600)
120+
require.NoError(t, err)
121+
122+
var queryComponents []string
123+
// We can't get an absolute path relative to empty string on Windows.
124+
if runtime.GOOS == "windows" && base == "" {
125+
queryComponents = pathToArray(tmpDir)
126+
} else {
127+
rel, err := filepath.Rel(base, tmpDir)
128+
require.NoError(t, err)
129+
queryComponents = pathToArray(rel)
130+
}
131+
132+
query := LSRequest{
133+
Path: queryComponents,
134+
Relativity: tc.relativity,
135+
}
136+
resp, err := listFiles(query)
137+
require.NoError(t, err)
138+
139+
require.Equal(t, tmpDir, resp.AbsolutePathString)
140+
require.ElementsMatch(t, []LSFile{
141+
{
142+
Name: "repos",
143+
AbsolutePathString: reposDir,
144+
IsDir: true,
145+
},
146+
{
147+
Name: "Downloads",
148+
AbsolutePathString: downloadsDir,
149+
IsDir: true,
150+
},
151+
{
152+
Name: "file.txt",
153+
AbsolutePathString: textFile,
154+
IsDir: false,
155+
},
156+
}, resp.Contents)
157+
})
158+
}
159+
}
160+
161+
func TestListFilesListDrives(t *testing.T) {
162+
t.Parallel()
163+
164+
if runtime.GOOS != "windows" {
165+
t.Skip("skipping test on non-Windows OS")
166+
}
167+
168+
query := LSRequest{
169+
Path: []string{},
170+
Relativity: LSRelativityRoot,
171+
}
172+
resp, err := listFiles(query)
173+
require.NoError(t, err)
174+
require.Contains(t, resp.Contents, LSFile{
175+
Name: "C:\\",
176+
AbsolutePathString: "C:\\",
177+
IsDir: true,
178+
})
179+
180+
query = LSRequest{
181+
Path: []string{"C:\\"},
182+
Relativity: LSRelativityRoot,
183+
}
184+
resp, err = listFiles(query)
185+
require.NoError(t, err)
186+
187+
query = LSRequest{
188+
Path: resp.AbsolutePath,
189+
Relativity: LSRelativityRoot,
190+
}
191+
resp, err = listFiles(query)
192+
require.NoError(t, err)
193+
// System directory should always exist
194+
require.Contains(t, resp.Contents, LSFile{
195+
Name: "Windows",
196+
AbsolutePathString: "C:\\Windows",
197+
IsDir: true,
198+
})
199+
200+
query = LSRequest{
201+
// Network drives are not supported.
202+
Path: []string{"\\sshfs\\work"},
203+
Relativity: LSRelativityRoot,
204+
}
205+
resp, err = listFiles(query)
206+
require.ErrorContains(t, err, "drive")
207+
}

0 commit comments

Comments
 (0)