- Notifications
You must be signed in to change notification settings - Fork913
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 ourterms of service andprivacy statement. We’ll occasionally send you account related emails.
Already on GitHub?Sign in to your account
Uh oh!
There was an error while loading.Please reload this page.
Changes fromall commits
a9abdcb
4c4adc8
d644fbd
81fca3b
e0089bd
01c2e72
b1e414f
369852c
c58b720
b2959db
c2f15f4
File filter
Filter by extension
Conversations
Uh oh!
There was an error while loading.Please reload this page.
Jump to
Uh oh!
There was an error while loading.Please reload this page.
Diff view
Diff view
There are no files selected for viewing
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) | ||
} | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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) | ||
} | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
// `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) | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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" | ||
) |
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) { | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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() | ||
ThomasK33 marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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) | ||
ethanndickson marked this conversation as resolved. Show resolvedHide resolvedUh oh!There was an error while loading.Please reload this page. | ||
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") | ||
} |
Uh oh!
There was an error while loading.Please reload this page.
Uh oh!
There was an error while loading.Please reload this page.