Skip to content

Commit 17f8e93

Browse files
chore: add agent endpoint for querying file system (coder#16736)
Closes coder/internal#382
1 parent eddccbc commit 17f8e93

File tree

5 files changed

+395
-3
lines changed

5 files changed

+395
-3
lines changed

agent/api.go

Lines changed: 1 addition & 0 deletions
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

Lines changed: 181 additions & 0 deletions
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

Lines changed: 207 additions & 0 deletions
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)
pFad - Phonifier reborn

Pfad - The Proxy pFad of © 2024 Garber Painting. All rights reserved.

Note: This service is not intended for secure transactions such as banking, social media, email, or purchasing. Use at your own risk. We assume no liability whatsoever for broken pages.


Alternative Proxies:

Alternative Proxy

pFad Proxy

pFad v3 Proxy

pFad v4 Proxy