mirror of
https://github.com/coder/coder.git
synced 2026-06-02 20:48:20 +00:00
ff9ed91811
This makes it so we can test it directly without having to go through Tailnet, which appears to be causing flakes in CI where the requests time out and never make it to the agent. Takes inspiration from the container-related API endpoints. Would probably make sense to refactor the ls tests to also go through the API (rather than be internal tests like they are currently) but I left those alone for now to keep the diff minimal.
190 lines
5.5 KiB
Go
190 lines
5.5 KiB
Go
package agentfiles
|
|
|
|
import (
|
|
"errors"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"regexp"
|
|
"runtime"
|
|
"slices"
|
|
"strings"
|
|
|
|
"github.com/shirou/gopsutil/v4/disk"
|
|
"github.com/spf13/afero"
|
|
"golang.org/x/xerrors"
|
|
|
|
"github.com/coder/coder/v2/coderd/httpapi"
|
|
"github.com/coder/coder/v2/codersdk"
|
|
"github.com/coder/coder/v2/codersdk/workspacesdk"
|
|
)
|
|
|
|
var WindowsDriveRegex = regexp.MustCompile(`^[a-zA-Z]:\\$`)
|
|
|
|
func (api *API) HandleLS(rw http.ResponseWriter, r *http.Request) {
|
|
ctx := r.Context()
|
|
|
|
// An absolute path may be optionally provided, otherwise a path split into an
|
|
// array must be provided in the body (which can be relative).
|
|
query := r.URL.Query()
|
|
parser := httpapi.NewQueryParamParser()
|
|
path := parser.String(query, "", "path")
|
|
parser.ErrorExcessParams(query)
|
|
if len(parser.Errors) > 0 {
|
|
httpapi.Write(ctx, rw, http.StatusBadRequest, codersdk.Response{
|
|
Message: "Query parameters have invalid values.",
|
|
Validations: parser.Errors,
|
|
})
|
|
return
|
|
}
|
|
|
|
var req workspacesdk.LSRequest
|
|
if !httpapi.Read(ctx, rw, r, &req) {
|
|
return
|
|
}
|
|
|
|
resp, err := listFiles(api.filesystem, path, req)
|
|
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(fs afero.Fs, path string, query workspacesdk.LSRequest) (workspacesdk.LSResponse, error) {
|
|
absolutePathString := path
|
|
if absolutePathString != "" {
|
|
if !filepath.IsAbs(path) {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("path must be absolute: %q", path)
|
|
}
|
|
} else {
|
|
var fullPath []string
|
|
switch query.Relativity {
|
|
case workspacesdk.LSRelativityHome:
|
|
home, err := os.UserHomeDir()
|
|
if err != nil {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get user home directory: %w", err)
|
|
}
|
|
fullPath = []string{home}
|
|
case workspacesdk.LSRelativityRoot:
|
|
if runtime.GOOS == "windows" {
|
|
if len(query.Path) == 0 {
|
|
return listDrives()
|
|
}
|
|
if !WindowsDriveRegex.MatchString(query.Path[0]) {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("invalid drive letter %q", query.Path[0])
|
|
}
|
|
} else {
|
|
fullPath = []string{"/"}
|
|
}
|
|
default:
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("unsupported relativity type %q", query.Relativity)
|
|
}
|
|
|
|
fullPath = append(fullPath, query.Path...)
|
|
fullPathRelative := filepath.Join(fullPath...)
|
|
var err error
|
|
absolutePathString, err = filepath.Abs(fullPathRelative)
|
|
if err != nil {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get absolute path of %q: %w", fullPathRelative, err)
|
|
}
|
|
}
|
|
|
|
// codeql[go/path-injection] - The intent is to allow the user to navigate to any directory in their workspace.
|
|
f, err := fs.Open(absolutePathString)
|
|
if err != nil {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to open directory %q: %w", absolutePathString, err)
|
|
}
|
|
defer f.Close()
|
|
|
|
stat, err := f.Stat()
|
|
if err != nil {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to stat directory %q: %w", absolutePathString, err)
|
|
}
|
|
|
|
if !stat.IsDir() {
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("path %q is not a directory", absolutePathString)
|
|
}
|
|
|
|
// `contents` may be partially populated even if the operation fails midway.
|
|
contents, _ := f.Readdir(-1)
|
|
respContents := make([]workspacesdk.LSFile, 0, len(contents))
|
|
for _, file := range contents {
|
|
respContents = append(respContents, workspacesdk.LSFile{
|
|
Name: file.Name(),
|
|
AbsolutePathString: filepath.Join(absolutePathString, file.Name()),
|
|
IsDir: file.IsDir(),
|
|
})
|
|
}
|
|
|
|
// Sort alphabetically: directories then files
|
|
slices.SortFunc(respContents, func(a, b workspacesdk.LSFile) int {
|
|
if a.IsDir && !b.IsDir {
|
|
return -1
|
|
}
|
|
if !a.IsDir && b.IsDir {
|
|
return 1
|
|
}
|
|
return strings.Compare(a.Name, b.Name)
|
|
})
|
|
|
|
absolutePath := pathToArray(absolutePathString)
|
|
|
|
return workspacesdk.LSResponse{
|
|
AbsolutePath: absolutePath,
|
|
AbsolutePathString: absolutePathString,
|
|
Contents: respContents,
|
|
}, nil
|
|
}
|
|
|
|
func listDrives() (workspacesdk.LSResponse, error) {
|
|
// disk.Partitions() will return partitions even if there was a failure to
|
|
// get one. Any errored partitions will not be returned.
|
|
partitionStats, err := disk.Partitions(true)
|
|
if err != nil && len(partitionStats) == 0 {
|
|
// Only return the error if there were no partitions returned.
|
|
return workspacesdk.LSResponse{}, xerrors.Errorf("failed to get partitions: %w", err)
|
|
}
|
|
|
|
contents := make([]workspacesdk.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)
|
|
contents = append(contents, workspacesdk.LSFile{
|
|
Name: name,
|
|
AbsolutePathString: name,
|
|
IsDir: true,
|
|
})
|
|
}
|
|
|
|
return workspacesdk.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
|
|
}
|