Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
200 changes: 200 additions & 0 deletions cli/cmd/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,200 @@
package cmd

import (
"fmt"
"os"
"sort"
"strconv"
"strings"
"unicode"

"github.com/raids-lab/crater/cli/internal/api"
"github.com/raids-lab/crater/cli/internal/completion"
"github.com/raids-lab/crater/cli/internal/i18n"
"github.com/raids-lab/crater/cli/internal/output"
"github.com/raids-lab/crater/cli/pkg/errorcodes"
"github.com/spf13/cobra"
)

var fileRemoteRoots = []string{"user", "public", "account"}

var fileCmd = &cobra.Command{
Use: "file",
Short: "View remote files",
Long: "List files in user, public, and account storage spaces.",
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) > 0 {
return errUnknownSubcommand(cmd, args[0])
}
return cmd.Help()
},
}

var fileLsCmd = &cobra.Command{
Use: "ls [remote-path]",
Short: "List remote files",
Args: maxOneArg,
RunE: runFileLs,
}

func runFileLs(_ *cobra.Command, args []string) error {
remotePath := ""
if len(args) == 1 {
remotePath = args[0]
}
normalizedPath, err := normalizeRemotePath(remotePath, true)
if err != nil {
return err
}

client, err := activeAPIClient()
if err != nil {
return err
}
files, err := client.ListFiles(normalizedPath)
if err != nil {
return cliErrFromAPI(err)
}
sortFileInfos(files)

if outputJSON {
return output.WriteSuccessJSON(os.Stdout, output.SuccessEnvelope(map[string]interface{}{
"files": files,
}))
}
printFileTable(files)
return nil
}

func normalizeRemotePath(remotePath string, allowEmpty bool) (string, error) {
if remotePath == "" {
if allowEmpty {
return "", nil
}
return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath))
}
if strings.ContainsRune(remotePath, '\\') {
return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath))
}
for _, character := range remotePath {
if unicode.IsControl(character) {
return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath))
}
}

normalized := strings.Trim(remotePath, "/")
if normalized == "" {
if allowEmpty {
return "", nil
}
return "", invalidRemotePathIssue(i18n.T("err_file_path_empty"))
}
rawSegments := strings.Split(normalized, "/")
segments := make([]string, 0, len(rawSegments))
for _, segment := range rawSegments {
if segment == ".." {
return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath))
}
if segment == "" || segment == "." {
continue
}
segments = append(segments, segment)
}
if len(segments) == 0 {
if allowEmpty {
return "", nil
}
return "", invalidRemotePathIssue(i18n.T("err_file_path_invalid", remotePath))
}
if !isFileRemoteRoot(segments[0]) {
return "", invalidRemotePathIssue(i18n.T("err_file_path_root", remotePath))
}
return strings.Join(segments, "/"), nil
}

func invalidRemotePathIssue(message string) error {
return errUsageFromIssues([]usageIssue{{
Code: errorcodes.ErrInvalidFlagValue,
Message: message,
Field: "remote-path",
}})
}

func isFileRemoteRoot(value string) bool {
for _, root := range fileRemoteRoots {
if value == root {
return true
}
}
return false
}

func sortFileInfos(files []api.FileInfo) {
sort.SliceStable(files, func(left, right int) bool {
if files[left].IsDir != files[right].IsDir {
return files[left].IsDir
}
leftName := strings.ToLower(files[left].Name)
rightName := strings.ToLower(files[right].Name)
if leftName == rightName {
return files[left].Name < files[right].Name
}
return leftName < rightName
})
}

func printFileTable(files []api.FileInfo) {
fmt.Printf("%s %s %s %s\n",
i18n.PadRight(i18n.T("table_name"), 36),
i18n.PadRight(i18n.T("table_type"), 12),
i18n.PadRight(i18n.T("file_table_size"), 14),
i18n.PadRight(i18n.T("file_table_modified"), 22))
for _, file := range files {
fileType := i18n.T("file_type_regular")
size := strconv.FormatInt(file.Size, 10)
if file.IsDir {
fileType = i18n.T("file_type_directory")
size = "-"
}
modified := "-"
if !file.ModifyTime.IsZero() {
modified = file.ModifyTime.Format("2006-01-02 15:04:05")
}
fmt.Printf("%s %s %s %s\n",
i18n.PadRight(displayFileName(file.Name), 36),
i18n.PadRight(fileType, 12),
i18n.PadRight(size, 14),
i18n.PadRight(modified, 22))
}
}

func displayFileName(name string) string {
for _, character := range name {
if unicode.IsControl(character) {
return strconv.QuoteToGraphic(name)
}
}
return name
}

func fileRootCompleter(ctx completion.Context) ([]completion.Candidate, error) {
prefix := strings.ToLower(completion.CurrentWordPrefix(ctx))
candidates := make([]completion.Candidate, 0, len(fileRemoteRoots))
for _, root := range fileRemoteRoots {
value := root
if prefix != "" && !strings.HasPrefix(value, prefix) {
continue
}
candidates = append(candidates, completion.Candidate{
Value: value,
Description: i18n.T("file_root_" + root + "_desc"),
})
}
return candidates, nil
}

func init() {
fileCmd.AddCommand(fileLsCmd)
rootCmd.AddCommand(fileCmd)
completion.RegisterPositional([]string{"file", "ls"}, 0, fileRootCompleter)
}
92 changes: 92 additions & 0 deletions cli/cmd/file_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
package cmd

import (
"reflect"
"testing"

"github.com/raids-lab/crater/cli/internal/api"
"github.com/raids-lab/crater/cli/internal/completion"
)

func TestNormalizeRemotePath(t *testing.T) {
tests := []struct {
name string
input string
allowEmpty bool
want string
wantErr bool
}{
{name: "visible root", allowEmpty: true, want: ""},
{name: "logical root", input: "user", want: "user"},
{name: "leading and trailing slash", input: "/public/实验 data/", want: "public/实验 data"},
{name: "account nested", input: "account/projects/run #1", want: "account/projects/run #1"},
{name: "empty rejected", input: "", wantErr: true},
{name: "unknown root", input: "admin/secret", wantErr: true},
{name: "parent traversal", input: "user/../public", wantErr: true},
{name: "current segment normalized", input: "user/./file", want: "user/file"},
{name: "duplicate slash normalized", input: "user//file", want: "user/file"},
{name: "backslash", input: `user\file`, wantErr: true},
{name: "control byte", input: "user/file\nname", wantErr: true},
}

for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
got, err := normalizeRemotePath(test.input, test.allowEmpty)
if (err != nil) != test.wantErr {
t.Fatalf("normalizeRemotePath(%q) error = %v, wantErr %v", test.input, err, test.wantErr)
}
if got != test.want {
t.Fatalf("normalizeRemotePath(%q) = %q, want %q", test.input, got, test.want)
}
})
}
}

func TestFileRootCompleterUsesStaticLogicalRoots(t *testing.T) {
candidates, err := fileRootCompleter(completion.Context{
Words: []string{"crater", "file", "ls", "u"},
Current: 4,
})
if err != nil {
t.Fatalf("fileRootCompleter: %v", err)
}
if len(candidates) != 1 || candidates[0].Value != "user" {
t.Fatalf("candidates = %#v, want user", candidates)
}
}

func TestSortFileInfosDirectoriesFirstThenName(t *testing.T) {
files := []api.FileInfo{
{Name: "z.bin", Size: 1},
{Name: "beta", IsDir: true},
{Name: "Alpha.txt", Size: 2},
{Name: "alpha", IsDir: true},
}
sortFileInfos(files)

got := []string{files[0].Name, files[1].Name, files[2].Name, files[3].Name}
want := []string{"alpha", "beta", "Alpha.txt", "z.bin"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("sorted names = %#v, want %#v", got, want)
}
}

func TestSortFileInfosKeepsEmptySliceStable(t *testing.T) {
files := []api.FileInfo{}
sortFileInfos(files)
if files == nil || len(files) != 0 {
t.Fatalf("files = %#v, want non-nil empty slice", files)
}
}

func TestDisplayFileNameEscapesTerminalControlCharacters(t *testing.T) {
if got := displayFileName("safe 文件.txt"); got != "safe 文件.txt" {
t.Fatalf("safe name = %q", got)
}
if got := displayFileName("line\nname"); got != `"line\nname"` {
t.Fatalf("control-character name = %q", got)
}
if got := displayFileName("color\x1b[31m"); got != `"color\x1b[31m"` {
t.Fatalf("escape-sequence name = %q", got)
}
}
27 changes: 26 additions & 1 deletion cli/docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -762,7 +762,7 @@ This section records the read-only API surface covered by the CLI after the broa
- Sensitive credential reads (`/token`, `/secret`, Harbor credential APIs) are not exposed in the broad read surface.
- WebSocket, terminal, and log streaming endpoints are not exposed because they are interactive/streaming rather than stable one-shot reads.
- The untracked local `inference-services` API is not documented here until that backend/frontend feature lands in the branch base.
- Public health, Swagger, Prometheus metrics, and low-level WebDAV file listing are left to their domain-specific tools rather than this first read CLI pass.
- Public health, Swagger, Prometheus metrics, and generic WebDAV operations are left to their domain-specific tools rather than this read CLI surface.

### Admin-Only Read Coverage
- `crater admin system-config llm|gpu-analysis|prequeue`: `/api/v1/admin/system-config/{llm,gpu-analysis,prequeue}`.
Expand All @@ -772,3 +772,28 @@ This section records the read-only API surface covered by the CLI after the broa
- `crater admin cronjobs`: `/api/v1/admin/operations/cronjob`.
- `crater admin whitelist`: `/api/v1/admin/operations/whitelist`.
- These commands surface existing admin GET APIs only. They do not perform update/delete/reconcile actions.

---

## 8. 远端文件模块 (file)

本模块面向普通用户访问 storage service 暴露的逻辑文件空间。远端路径不是本机路径,只允许以 `user`、`public` 或 `account` 为首段;CLI 会规范化安全的 `.`、重复分隔符和首尾分隔符,逐段进行 URL 编码,保留合法的空格与非 ASCII 文件名,并在请求前拒绝任何 `..` 段、反斜杠和控制字符。

### `crater file ls [remote-path]`

- **描述**:列出当前用户可见的远端文件或目录。
- **位置参数**:
- `[remote-path]`(可选):逻辑远端目录。省略时列出可见根目录;可用根为 `user`、`public`、`account`。
- **处理逻辑**:
- 调用 `GET /api/ss/files` 或 `GET /api/ss/files/*path`。
- 目录排在普通文件之前,同类型条目按名称稳定排序。
- 空目录返回稳定的空列表。
- 本命令只读取普通用户文件视图,不会切换到管理员接口。
- **输出格式**:
- 默认模式:表格展示 `NAME`、`TYPE`、`SIZE`、`MODIFIED`;目录的大小显示为 `-`。
- `--json`:stdout 输出成功信封 JSON。
- **`--json` 的 `data`**:
- `files`(数组):文件条目,每项包含 `name`、`size`、`isdir`、`modifytime`。
- **状态**:[x] Completed

下载、上传、创建目录、移动和删除不属于本命令范围,由各自独立的文件命令契约定义。
58 changes: 58 additions & 0 deletions cli/internal/api/file.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package api

import (
"net/url"
"strings"
"time"
)

// FileClient exposes the ordinary-user remote file APIs used by the CLI.
type FileClient interface {
ListFiles(remotePath string) ([]FileInfo, error)
}

// NewFileClient creates a typed remote-file client.
func NewFileClient(baseURL, token string) FileClient {
return NewClient(baseURL).SetToken(token)
}

// FileInfo is the stable subset of the storage service file-list response.
type FileInfo struct {
Name string `json:"name"`
Size int64 `json:"size"`
IsDir bool `json:"isdir"`
ModifyTime time.Time `json:"modifytime"`
}

// ListFiles lists a logical user-visible storage path. remotePath must already
// be validated and normalized by the command layer.
func (c *Client) ListFiles(remotePath string) ([]FileInfo, error) {
requestPath := FileListPath
if remotePath != "" {
requestPath += "/" + escapeRemotePath(remotePath)
}

var result Response[[]FileInfo]
resp, err := c.httpClient.R().
SetSuccessResult(&result).
SetErrorResult(&result).
Get(requestPath)
if err != nil {
return nil, &NetworkError{Cause: err}
}
if err := errorFromResponse(resp, result.Code, result.Message); err != nil {
return nil, err
}
if result.Data == nil {
return []FileInfo{}, nil
}
return result.Data, nil
}

func escapeRemotePath(remotePath string) string {
segments := strings.Split(remotePath, "/")
for index := range segments {
segments[index] = url.PathEscape(segments[index])
}
return strings.Join(segments, "/")
}
Loading