Skip to content

Commit 9b1dfcb

Browse files
committed
feat: add agent endpoint for querying file system
1 parent 7e33902 commit 9b1dfcb

File tree

3 files changed

+308
-0
lines changed

3 files changed

+308
-0
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/ls", 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

+136
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,136 @@
1+
package agent
2+
3+
import (
4+
"errors"
5+
"net/http"
6+
"os"
7+
"path/filepath"
8+
"runtime"
9+
10+
"golang.org/x/xerrors"
11+
12+
"github.com/coder/coder/v2/coderd/httpapi"
13+
"github.com/coder/coder/v2/codersdk"
14+
)
15+
16+
func (*agent) HandleLS(rw http.ResponseWriter, r *http.Request) {
17+
ctx := r.Context()
18+
19+
var query LSQuery
20+
if !httpapi.Read(ctx, rw, r, &query) {
21+
return
22+
}
23+
24+
resp, err := listFiles(query)
25+
if err != nil {
26+
switch {
27+
case errors.Is(err, os.ErrNotExist):
28+
httpapi.Write(ctx, rw, http.StatusNotFound, codersdk.Response{
29+
Message: "Directory does not exist",
30+
})
31+
case errors.Is(err, os.ErrPermission):
32+
httpapi.Write(ctx, rw, http.StatusForbidden, codersdk.Response{
33+
Message: "Permission denied",
34+
})
35+
default:
36+
httpapi.Write(ctx, rw, http.StatusInternalServerError, codersdk.Response{
37+
Message: err.Error(),
38+
})
39+
}
40+
return
41+
}
42+
43+
httpapi.Write(ctx, rw, http.StatusOK, resp)
44+
}
45+
46+
func listFiles(query LSQuery) (LSResponse, error) {
47+
var base string
48+
switch query.Relativity {
49+
case LSRelativityHome:
50+
home, err := os.UserHomeDir()
51+
if err != nil {
52+
return LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
53+
}
54+
base = home
55+
case LSRelativityRoot:
56+
if runtime.GOOS == "windows" {
57+
// TODO: Eventually, we could have a empty path with a root base
58+
// return all drives.
59+
// C drive should be good enough for now.
60+
base = "C:\\"
61+
} else {
62+
base = "/"
63+
}
64+
default:
65+
return LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
66+
}
67+
68+
fullPath := append([]string{base}, query.Path...)
69+
absolutePathString, err := filepath.Abs(filepath.Join(fullPath...))
70+
if err != nil {
71+
return LSResponse{}, xerrors.Errorf("failed to get absolute path: %w", err)
72+
}
73+
74+
f, err := os.Open(absolutePathString)
75+
if err != nil {
76+
return LSResponse{}, xerrors.Errorf("failed to open directory: %w", err)
77+
}
78+
defer f.Close()
79+
80+
stat, err := f.Stat()
81+
if err != nil {
82+
return LSResponse{}, xerrors.Errorf("failed to stat directory: %w", err)
83+
}
84+
85+
if !stat.IsDir() {
86+
return LSResponse{}, xerrors.New("path is not a directory")
87+
}
88+
89+
// `contents` may be partially populated even if the operation fails midway.
90+
contents, _ := f.Readdir(-1)
91+
respContents := make([]LSFile, 0, len(contents))
92+
for _, file := range contents {
93+
respContents = append(respContents, LSFile{
94+
Name: file.Name(),
95+
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
96+
IsDir: file.IsDir(),
97+
})
98+
}
99+
100+
return LSResponse{
101+
AbsolutePathString: absolutePathString,
102+
Contents: respContents,
103+
}, nil
104+
}
105+
106+
type LSQuery struct {
107+
// e.g. [], ["repos", "coder"],
108+
Path []string `json:"path"`
109+
// Whether the supplied path is relative to the user's home directory,
110+
// or the root directory.
111+
Relativity LSRelativity `json:"relativity"`
112+
}
113+
114+
type LSResponse struct {
115+
// Returned so clients can display the full path to the user, and
116+
// copy it to configure file sync
117+
// e.g. Windows: "C:\\Users\\coder"
118+
// Linux: "/home/coder"
119+
AbsolutePathString string `json:"absolute_path_string"`
120+
Contents []LSFile `json:"contents"`
121+
}
122+
123+
type LSFile struct {
124+
Name string `json:"name"`
125+
// e.g. "C:\\Users\\coder\\hello.txt"
126+
// "/home/coder/hello.txt"
127+
AbsolutePathString string `json:"absolute_path_string"`
128+
IsDir bool `json:"is_dir"`
129+
}
130+
131+
type LSRelativity string
132+
133+
const (
134+
LSRelativityRoot LSRelativity = "root"
135+
LSRelativityHome LSRelativity = "home"
136+
)

agent/ls_internal_test.go

+171
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,171 @@
1+
package agent
2+
3+
import (
4+
"os"
5+
"path/filepath"
6+
"runtime"
7+
"strings"
8+
"testing"
9+
10+
"github.com/stretchr/testify/require"
11+
)
12+
13+
func TestListFilesNonExistentDirectory(t *testing.T) {
14+
t.Parallel()
15+
16+
query := LSQuery{
17+
Path: []string{"idontexist"},
18+
Relativity: LSRelativityHome,
19+
}
20+
_, err := listFiles(query)
21+
require.ErrorIs(t, err, os.ErrNotExist)
22+
}
23+
24+
func TestListFilesPermissionDenied(t *testing.T) {
25+
t.Parallel()
26+
27+
if runtime.GOOS == "windows" {
28+
t.Skip("creating an unreadable-by-user directory is non-trivial on Windows")
29+
}
30+
31+
home, err := os.UserHomeDir()
32+
require.NoError(t, err)
33+
34+
tmpDir := t.TempDir()
35+
36+
reposDir := filepath.Join(tmpDir, "repos")
37+
err = os.Mkdir(reposDir, 0o000)
38+
require.NoError(t, err)
39+
40+
rel, err := filepath.Rel(home, reposDir)
41+
require.NoError(t, err)
42+
43+
query := LSQuery{
44+
Path: pathToArray(rel),
45+
Relativity: LSRelativityHome,
46+
}
47+
_, err = listFiles(query)
48+
require.ErrorIs(t, err, os.ErrPermission)
49+
}
50+
51+
func TestListFilesNotADirectory(t *testing.T) {
52+
t.Parallel()
53+
54+
home, err := os.UserHomeDir()
55+
require.NoError(t, err)
56+
57+
tmpDir := t.TempDir()
58+
59+
filePath := filepath.Join(tmpDir, "file.txt")
60+
err = os.WriteFile(filePath, []byte("content"), 0o600)
61+
require.NoError(t, err)
62+
63+
rel, err := filepath.Rel(home, filePath)
64+
require.NoError(t, err)
65+
66+
query := LSQuery{
67+
Path: pathToArray(rel),
68+
Relativity: LSRelativityHome,
69+
}
70+
_, err = listFiles(query)
71+
require.ErrorContains(t, err, "path is not a directory")
72+
}
73+
74+
func TestListFilesSuccess(t *testing.T) {
75+
t.Parallel()
76+
77+
tc := []struct {
78+
name string
79+
baseFunc func(t *testing.T) string
80+
relativity LSRelativity
81+
}{
82+
{
83+
name: "home",
84+
baseFunc: func(t *testing.T) string {
85+
home, err := os.UserHomeDir()
86+
require.NoError(t, err)
87+
return home
88+
},
89+
relativity: LSRelativityHome,
90+
},
91+
{
92+
name: "root",
93+
baseFunc: func(t *testing.T) string {
94+
if runtime.GOOS == "windows" {
95+
return "C:\\"
96+
}
97+
return "/"
98+
},
99+
relativity: LSRelativityRoot,
100+
},
101+
}
102+
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+
rel, err := filepath.Rel(base, tmpDir)
119+
require.NoError(t, err)
120+
relComponents := pathToArray(rel)
121+
122+
query := LSQuery{
123+
Path: relComponents,
124+
Relativity: tc.relativity,
125+
}
126+
resp, err := listFiles(query)
127+
require.NoError(t, err)
128+
129+
require.Equal(t, tmpDir, resp.AbsolutePathString)
130+
131+
var foundRepos, foundDownloads bool
132+
for _, file := range resp.Contents {
133+
switch file.Name {
134+
case "repos":
135+
foundRepos = true
136+
expectedPath := filepath.Join(tmpDir, "repos")
137+
require.Equal(t, expectedPath, file.AbsolutePathString)
138+
require.True(t, file.IsDir)
139+
case "Downloads":
140+
foundDownloads = true
141+
expectedPath := filepath.Join(tmpDir, "Downloads")
142+
require.Equal(t, expectedPath, file.AbsolutePathString)
143+
require.True(t, file.IsDir)
144+
}
145+
}
146+
require.True(t, foundRepos && foundDownloads, "expected to find both repos and Downloads directories, got: %+v", resp.Contents)
147+
})
148+
}
149+
}
150+
151+
func TestListFilesWindowsRoot(t *testing.T) {
152+
t.Parallel()
153+
154+
if runtime.GOOS != "windows" {
155+
t.Skip("skipping test on non-Windows OS")
156+
}
157+
158+
query := LSQuery{
159+
Path: []string{},
160+
Relativity: LSRelativityRoot,
161+
}
162+
resp, err := listFiles(query)
163+
require.NoError(t, err)
164+
require.Equal(t, "C:\\", resp.AbsolutePathString)
165+
}
166+
167+
func pathToArray(path string) []string {
168+
return strings.FieldsFunc(path, func(r rune) bool {
169+
return r == os.PathSeparator
170+
})
171+
}

0 commit comments

Comments
 (0)