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

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/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

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

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