feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限

This commit is contained in:
Lemon-miaow committed 2026-09-27 19:58:28 +08:00
1 parent b6751eac0f
commit 051cc1c9f5
37 files changed
+5923 -367

No files matched your search

+25
View File
@@ -300,12 +300,22 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// endpoints honestly return 503. It takes the typed clientset rather than the // endpoints honestly return 503. It takes the typed clientset rather than the
// controller-runtime client because the log subresource lives only on the typed // controller-runtime client because the log subresource lives only on the typed
// CoreV1 client, and one client covers its Job create, Pod list, and log read. // CoreV1 client, and one client covers its Job create, Pod list, and log read.
//
// Uploads additionally stage their bytes on this pod's disk until the Job
// fetches them from the internal face; whatever a previous process staged is
// orphaned (the index is in memory), so the stage starts empty.
var files api.FileEditor var files api.FileEditor
var fileStage *fileedit.Stage
if felisImage != "" { if felisImage != "" {
files = &fileedit.Editor{ files = &fileedit.Editor{
Runner: fileedit.NewK8sRunner(clientset), Runner: fileedit.NewK8sRunner(clientset),
Config: fileEditConfig(cfg, felisImage), Config: fileEditConfig(cfg, felisImage),
} }
fileStage = &fileedit.Stage{Dir: fileStagingDir()}
if err := fileStage.Sweep(); err != nil {
fmt.Fprintf(stderr, "felis api: %v — file uploads return 503\n", err)
fileStage = nil
}
} else { } else {
fmt.Fprintln(stderr, "felis api: file editor disabled (needs FELIS_IMAGE) — file endpoints return 503") fmt.Fprintln(stderr, "felis api: file editor disabled (needs FELIS_IMAGE) — file endpoints return 503")
} }
@@ -353,6 +363,11 @@ func cmdAPI(args []string, stdout, stderr io.Writer) int {
// restore behind each one. // restore behind each one.
RestoreChains: jobStatus, RestoreChains: jobStatus,
Files: files, Files: files,
FileStage: fileStage,
// The file Job fetches an upload from here; it runs in the minecraft
// namespace, where the internal face is reachable like it is for the login
// gate.
InternalBaseURL: internalAPIBaseURL(),
Submissions: submissions, Submissions: submissions,
Mailer: mailer, Mailer: mailer,
// The external face authenticates the local session cookie the sign-in doors // The external face authenticates the local session cookie the sign-in doors
@@ -947,6 +962,16 @@ func uploadPartsDir(contextBase string) string {
return filepath.Join(os.TempDir(), "felis-upload-parts") return filepath.Join(os.TempDir(), "felis-upload-parts")
} }
// fileStagingDir is where file uploads wait for their Job: on the uploads
// volume, whose capacity is its own, or the pod's /tmp when run by hand without
// it — /tmp is the node's disk, which a burst of uploads should not fill.
func fileStagingDir() string {
if fi, err := os.Stat(platform.UploadsLocalPath); err == nil && fi.IsDir() {
return filepath.Join(platform.UploadsLocalPath, ".file-staging")
}
return filepath.Join(os.TempDir(), "felis-file-staging")
}
// contextMaxBytes resolves [registry] context_max_bytes. 0 keeps the submit // contextMaxBytes resolves [registry] context_max_bytes. 0 keeps the submit
// package's own default (1 GiB). The Cloudflare edge refuses a single request // package's own default (1 GiB). The Cloudflare edge refuses a single request
// body over 100 MB, which the panel's chunked upload stays under, so the edge // body over 100 MB, which the panel's chunked upload stays under, so the edge
+68 -20
View File
@@ -1,11 +1,15 @@
package main package main
import ( import (
"encoding/base64" "context"
"flag" "flag"
"fmt" "fmt"
"io" "io"
"net/http"
"os" "os"
"os/signal"
"syscall"
"time"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
) )
@@ -20,32 +24,42 @@ import (
// config.Load: felis-api made the authorization decision (the caller owns this // config.Load: felis-api made the authorization decision (the caller owns this
// server, and the server is stopped so the RWO world volume is free); this process // server, and the server is stopped so the RWO world volume is free); this process
// is the unprivileged hands that touch bytes. Its entire input is the flags // is the unprivileged hands that touch bytes. Its entire input is the flags
// below plus, for a write, one environment variable. Every isolation guarantee // below plus, for a write, the content variables and, for an upload, one token.
// lives in the Pod spec (internal/fileedit/jobspec.go), and the path-containment // Every isolation guarantee lives in the Pod spec (internal/fileedit/jobspec.go),
// guarantee lives in fileedit.Execute, which resolves the path through os.Root and // and the path-containment guarantee lives in fileedit.Execute, which resolves
// therefore cannot be walked out of the world mount. // every path through os.Root and therefore cannot be walked out of the world
// mount.
// //
// Exit status carries a specific meaning that felis-api depends on: a CALLER-fault // Exit status carries a specific meaning that felis-api depends on: a CALLER-fault
// outcome — a path that escapes the root, a file that is missing or too large — is // outcome — a path that escapes the root, a file that is missing or too large — is
// a SUCCESSFUL run that prints a Result carrying an error code, so the API can map // a SUCCESSFUL run that prints a Result carrying an error code, so the API can map
// it to a precise 4xx. A non-zero exit means the operation could not be attempted // it to a precise 4xx. A non-zero exit means the operation could not be attempted
// at all (the world mount is unreadable, the result unprintable), which the API // at all (the world mount is unreadable, an upload's bytes could not be fetched
// reports as a 500. // intact, the result unprintable), which the API reports as a 500.
func cmdFiles(args []string, stdout, stderr io.Writer) int { func cmdFiles(args []string, stdout, stderr io.Writer) int {
fs := flag.NewFlagSet("files", flag.ContinueOnError) fs := flag.NewFlagSet("files", flag.ContinueOnError)
fs.SetOutput(stderr) fs.SetOutput(stderr)
op := fs.String("op", "", "operation: list, read, or write") op := fs.String("op", "", "operation: list, read, write, mkdir, delete, rename or upload")
path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)") path := fs.String("path", "", "path to operate on, relative to the world root (empty = the root itself)")
worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it") worldsRoot := fs.String("worlds-root", "/data", "mount path of the world PVC; every path resolves under it")
expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this") expect := fs.String("expect-sha256", "", "write only: refuse unless the file's current SHA-256 (hex) is this")
createOnly := fs.Bool("create-only", false, "write only: refuse a path that already exists")
to := fs.String("to", "", "rename only: the destination path")
sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from")
size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have")
sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have")
overwrite := fs.Bool("overwrite", false, "upload only: replace a file already at the path")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
} }
if *op == "" { if *op == "" {
fmt.Fprintln(stderr, "felis files: --op is required (list, read, or write)") fmt.Fprintln(stderr, "felis files: --op is required")
return 2 return 2
} }
req := fileedit.Request{
Op: *op, Path: *path, To: *to, Expect: *expect, CreateOnly: *createOnly, Overwrite: *overwrite,
}
// New content arrives base64-encoded in the environment rather than in argv: // New content arrives base64-encoded in the environment rather than in argv:
// a process's arguments are world-readable on the node (/proc/<pid>/cmdline), // a process's arguments are world-readable on the node (/proc/<pid>/cmdline),
@@ -53,22 +67,29 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
// secrets — an RCON password in server.properties is the obvious case. The // secrets — an RCON password in server.properties is the obvious case. The
// encoding is what lets arbitrary bytes (CRLF endings, a BOM, a NUL) survive a // encoding is what lets arbitrary bytes (CRLF endings, a BOM, a NUL) survive a
// channel that must be a valid string. // channel that must be a valid string.
var content []byte switch *op {
if *op == fileedit.OpWrite { case fileedit.OpWrite:
raw, ok := os.LookupEnv(fileedit.ContentEnv) content, err := fileedit.ContentFromEnv(os.LookupEnv)
if !ok {
fmt.Fprintf(stderr, "felis files: a write needs %s in the environment\n", fileedit.ContentEnv)
return 2
}
decoded, err := base64.StdEncoding.DecodeString(raw)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis files: %s is not valid base64: %v\n", fileedit.ContentEnv, err) fmt.Fprintf(stderr, "felis files: %v\n", err)
return 2 return 2
} }
content = decoded req.Content = content
case fileedit.OpUpload:
token := os.Getenv(fileedit.UploadTokenEnv)
if *sourceURL == "" || token == "" {
fmt.Fprintf(stderr, "felis files: an upload needs --source-url and %s\n", fileedit.UploadTokenEnv)
return 2
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
req.Upload = &fileedit.Upload{
Size: *size, SHA256: *sum,
Open: func() (io.ReadCloser, error) { return fetchUpload(ctx, *sourceURL, token) },
}
} }
res, err := fileedit.Execute(*worldsRoot, *op, *path, content, *expect) res, err := fileedit.Execute(*worldsRoot, req)
if err != nil { if err != nil {
// The operation could not be attempted — infrastructure, not caller fault. // The operation could not be attempted — infrastructure, not caller fault.
fmt.Fprintf(stderr, "felis files: %v\n", err) fmt.Fprintf(stderr, "felis files: %v\n", err)
@@ -83,3 +104,30 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
} }
return 0 return 0
} }
// fetchUpload opens the staged upload on felis-api's internal face. There is no
// retry: the token opens the upload once (fileedit.Stage), so a second attempt
// could only be refused, and felis-api answers the failed Job with a 500 the
// caller can retry whole. Redirects are refused because the request carries the
// token and the internal face never redirects; the header timeout catches a
// wedged endpoint, and the Job's activeDeadlineSeconds bounds the body.
func fetchUpload(ctx context.Context, url, token string) (io.ReadCloser, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", "Bearer "+token)
client := &http.Client{
Transport: &http.Transport{ResponseHeaderTimeout: 30 * time.Second},
CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse },
}
resp, err := client.Do(req)
if err != nil {
return nil, err
}
if resp.StatusCode != http.StatusOK {
resp.Body.Close()
return nil, fmt.Errorf("GET returned %s", resp.Status)
}
return resp.Body, nil
}
+203
View File
@@ -0,0 +1,203 @@
package main
import (
"bytes"
"crypto/sha256"
"encoding/base64"
"encoding/hex"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync/atomic"
"testing"
"felis.lolicon.best/internal/fileedit"
)
// filesResult is the Result a `felis files` run printed on its marked line.
func filesResult(t *testing.T, stdout string) fileedit.Result {
t.Helper()
line, ok := strings.CutPrefix(strings.TrimSpace(stdout), fileedit.ResultPrefix)
if !ok {
t.Fatalf("stdout has no result line: %q", stdout)
}
var res fileedit.Result
if err := json.Unmarshal([]byte(line), &res); err != nil {
t.Fatalf("result line %q: %v", line, err)
}
return res
}
// stagedUpload serves body to a request carrying Bearer token, and 404 to any
// other, the way felis-api's internal face does.
func stagedUpload(t *testing.T, token string, body []byte) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer "+token {
http.Error(w, "no such upload", http.StatusNotFound)
return
}
w.Write(body)
}))
t.Cleanup(srv.Close)
return srv
}
func uploadArgs(root, sourceURL string, body []byte) []string {
sum := sha256.Sum256(body)
return []string{
"--op", "upload", "--path", "plugins/a.jar", "--worlds-root", root,
"--source-url", sourceURL, "--size", "4", "--sha256", hex.EncodeToString(sum[:]),
}
}
// uploadRoot is a world with the plugins folder an upload lands in.
func uploadRoot(t *testing.T) string {
t.Helper()
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "plugins"), 0o755); err != nil {
t.Fatal(err)
}
return root
}
func TestCmdFilesUpload(t *testing.T) {
body := []byte("PK\x03\x04")
t.Run("fetches the staged bytes with its token and lands them", func(t *testing.T) {
root := uploadRoot(t)
srv := stagedUpload(t, "tok", body)
t.Setenv(fileedit.UploadTokenEnv, "tok")
var stdout, stderr bytes.Buffer
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != "" {
t.Fatalf("result = %+v", res)
}
got, err := os.ReadFile(filepath.Join(root, "plugins", "a.jar"))
if err != nil || !bytes.Equal(got, body) {
t.Fatalf("landed %q, %v", got, err)
}
})
// A refused fetch is the Job failing, never a Result: the API answers it with a
// 500 the caller retries whole.
t.Run("a refused fetch exits 1 and lands nothing", func(t *testing.T) {
root := uploadRoot(t)
srv := stagedUpload(t, "tok", body)
t.Setenv(fileedit.UploadTokenEnv, "wrong")
var stdout, stderr bytes.Buffer
if code := cmdFiles(uploadArgs(root, srv.URL+"/u", body), &stdout, &stderr); code != 1 {
t.Fatalf("exit %d, want 1; stdout %q", code, stdout.String())
}
if !strings.Contains(stderr.String(), "404") {
t.Fatalf("stderr %q does not name the status", stderr.String())
}
if _, err := os.Lstat(filepath.Join(root, "plugins", "a.jar")); !os.IsNotExist(err) {
t.Fatalf("a refused fetch left a file: %v", err)
}
})
// The request carries the token, and the internal face never redirects, so a
// redirect is refused rather than followed with the token attached.
t.Run("a redirect is not followed", func(t *testing.T) {
root := uploadRoot(t)
var hits atomic.Int32
elsewhere := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.Write(body)
}))
defer elsewhere.Close()
redirecting := httptest.NewServer(http.RedirectHandler(elsewhere.URL+"/u", http.StatusFound))
defer redirecting.Close()
t.Setenv(fileedit.UploadTokenEnv, "tok")
var stdout, stderr bytes.Buffer
if code := cmdFiles(uploadArgs(root, redirecting.URL+"/u", body), &stdout, &stderr); code != 1 {
t.Fatalf("exit %d, want 1", code)
}
if n := hits.Load(); n != 0 {
t.Fatalf("the redirect target was fetched %d times", n)
}
})
for name, tc := range map[string]struct {
token string
drop string
}{
"no token": {"", ""},
"no source URL": {"tok", "--source-url"},
} {
t.Run(name+" exits 2", func(t *testing.T) {
srv := stagedUpload(t, "tok", body)
t.Setenv(fileedit.UploadTokenEnv, tc.token)
args := uploadArgs(uploadRoot(t), srv.URL+"/u", body)
if tc.drop != "" {
for i, a := range args {
if a == tc.drop {
args = append(args[:i:i], args[i+2:]...)
break
}
}
}
var stdout, stderr bytes.Buffer
if code := cmdFiles(args, &stdout, &stderr); code != 2 {
t.Fatalf("exit %d, want 2", code)
}
})
}
}
func TestCmdFilesWrite(t *testing.T) {
root := t.TempDir()
args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root}
t.Run("reassembles the content parts", func(t *testing.T) {
content := []byte("[]\r\n")
t.Setenv(fileedit.ContentPartsEnv, "1")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
var stdout, stderr bytes.Buffer
if code := cmdFiles(args, &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if got, err := os.ReadFile(filepath.Join(root, "ops.json")); err != nil || !bytes.Equal(got, content) {
t.Fatalf("wrote %q, %v", got, err)
}
})
// Writing what did arrive of an incomplete spec would truncate the file.
t.Run("an incomplete content spec exits 2 and writes nothing", func(t *testing.T) {
t.Setenv(fileedit.ContentPartsEnv, "2")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x")))
var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 {
t.Fatalf("exit %d, want 2", code)
}
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("an incomplete spec wrote a file: %v", err)
}
})
}
// A caller-fault outcome is a successful run carrying a code, so felis-api can
// answer the precise 4xx instead of a 500.
func TestCmdFilesCallerFaultIsAResult(t *testing.T) {
root := t.TempDir()
var stdout, stderr bytes.Buffer
if code := cmdFiles([]string{"--op", "mkdir", "--path", "../out", "--worlds-root", root}, &stdout, &stderr); code != 0 {
t.Fatalf("exit %d, stderr %q", code, stderr.String())
}
if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeBadPath {
t.Fatalf("result = %+v, want code %s", res, fileedit.CodeBadPath)
}
stdout.Reset()
if code := cmdFiles([]string{"--worlds-root", root}, &stdout, &stderr); code != 2 {
t.Fatalf("no --op: exit %d, want 2", code)
}
}
+323 -1
View File
@@ -1202,6 +1202,38 @@ paths:
'503': '503':
$ref: '#/components/responses/ServiceUnavailable' $ref: '#/components/responses/ServiceUnavailable'
/api/v1/internal/file-uploads/{id}:
get:
tags: [files]
operationId: internalFileUpload
summary: Stream one staged file upload to the Job landing it (one-time bearer token).
description: >-
PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk
and creates a Job to land it in the world volume; the Job fetches the bytes
here. The Job holds no service token, so the route is public on the internal
face and the bearer token minted with the upload is the whole check. The
token opens its upload once. An unknown id, a wrong or missing token and a
spent token are all the same 404, so the route says nothing about which
uploads exist.
x-felis-face: [internal]
x-felis-tier: public
security: []
parameters:
- { name: id, in: path, required: true, schema: { type: string } }
- name: Authorization
in: header
required: true
description: Bearer followed by the token minted with the upload.
schema: { type: string }
responses:
'200':
description: The staged bytes, verbatim, with their Content-Length.
content:
application/octet-stream:
schema: { type: string, format: binary }
'404':
$ref: '#/components/responses/NotFound'
/api/v1/internal/servers/{name}/join-event: /api/v1/internal/servers/{name}/join-event:
post: post:
tags: [servers-internal] tags: [servers-internal]
@@ -4224,6 +4256,12 @@ paths:
The sha256 a read returned. When present, the write is refused with The sha256 a read returned. When present, the write is refused with
409 file_changed if the file has changed (or been deleted) since. 409 file_changed if the file has changed (or been deleted) since.
Omit it to write unconditionally. Omit it to write unconditionally.
create_only:
type: boolean
description: >-
true writes only if nothing is at the path yet (409 file_exists
otherwise), for making a new file without replacing one that
appeared meanwhile. Cannot be combined with expect_sha256.
responses: responses:
'200': '200':
description: File written. description: File written.
@@ -4251,7 +4289,11 @@ paths:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
'409': '409':
description: Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress). description: >-
The file changed since expect_sha256 was read (file_changed), something is
already at the path with create_only (file_exists), the server is not
stopped (not_stopped), or a restore, backup or file change already holds its
world volume (maintenance_in_progress).
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
@@ -4272,6 +4314,286 @@ paths:
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
delete:
tags: [files]
operationId: deleteServerFile
summary: Delete a file or folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Deletes a file, a symlink (never what it points at) or a folder with
everything in it. The world root itself is refused (400 bad_path). Same
stopped-gate, world lock and os.Root containment as a write. The panel
confirms first; this route does not. Audited as file.delete.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File or folder to delete, relative to the world root.
schema: { type: string }
responses:
'200':
description: Deleted.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: deleted }
'400':
description: Missing path, invalid server name, the world root, or a path that escapes it.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or nothing at the path.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/mkdir:
post:
tags: [files]
operationId: makeServerFolder
summary: Make a folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Makes one folder. Its parent must already exist (404), and nothing may be at
the path yet (409 file_exists). Same stopped-gate, world lock and os.Root
containment as a write. Audited as file.mkdir.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: Folder to make, relative to the world root.
schema: { type: string }
responses:
'200':
description: Folder made.
content:
application/json:
schema:
type: object
required: [path, status]
properties:
path: { type: string }
status: { type: string, const: created }
'400':
description: Missing path, invalid server name, the world root, or a path that escapes it.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the parent folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Something is already at the path (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/rename:
post:
tags: [files]
operationId: renameServerFile
summary: Move or rename a file or folder in a server's world volume (owner-or-admin; server must be stopped).
description: >-
Moves the file or folder at path to to. It never replaces: an existing
destination is 409 file_exists, and a missing destination folder is 404.
server.properties, config/paper-global.yml and config/ cannot be moved under
any name they are reached by (400 bad_path), because elsewhere the read path
would no longer withhold their secrets. Same stopped-gate, world lock and
os.Root containment as a write. Audited as file.rename.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File or folder to move, relative to the world root.
schema: { type: string }
requestBody:
required: true
content:
application/json:
schema:
type: object
required: [to]
properties:
to: { type: string, minLength: 1, description: The new path, relative to the world root. }
responses:
'200':
description: Moved.
content:
application/json:
schema:
type: object
required: [path, to, status]
properties:
path: { type: string }
to: { type: string }
status: { type: string, const: renamed }
'400':
description: Missing path or to, malformed body, invalid server name, the world root, a file felis manages, or a path that escapes the world root.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, nothing at path, or the destination folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: Something is already at to (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
/api/v1/servers/{name}/files/upload:
put:
tags: [files]
operationId: uploadServerFile
summary: Upload a file into a server's world volume (owner-or-admin; server must be stopped).
description: >-
Lands the raw request body as the file at path, up to 64 MiB — a plugin jar,
a datapack, a world region. Content-Length is required (411
length_required). An existing file is 409 file_exists unless overwrite=true;
a folder at the path is 400 bad_path either way. The body is staged on
felis-api's disk first and then fetched by the file Job with a one-time
token, so the world lock is taken only after the body has arrived and a slow
upload holds off no backup. The file lands atomically: a synced temporary
sibling is checked against the staged size and SHA-256, then renamed into
place, so a failed upload leaves the old file whole. Same stopped-gate and
os.Root containment as a write. Audited as file.upload.
x-felis-face: [external]
x-felis-tier: app
security: [{ sessionCookie: [] }]
parameters:
- { name: name, in: path, required: true, schema: { type: string } }
- name: path
in: query
required: true
description: File to create, relative to the world root. Its folder must exist.
schema: { type: string }
- name: overwrite
in: query
required: false
description: true replaces an existing file, keeping its mode. Anything else refuses to.
schema: { type: string, enum: ["true", "false"] }
requestBody:
required: true
content:
application/octet-stream:
schema: { type: string, format: binary }
responses:
'200':
description: File uploaded.
content:
application/json:
schema:
type: object
required: [path, status, sha256, size]
properties:
path: { type: string }
status: { type: string, const: uploaded }
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes landed. }
size: { type: integer, format: int64, description: Bytes landed. }
'400':
description: >-
Missing path, invalid server name, a folder or the world root at the path,
a path that escapes the world root, or a body that ended before
Content-Length bytes arrived (upload_incomplete).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'401':
$ref: '#/components/responses/Unauthorized'
'403':
$ref: '#/components/responses/Forbidden'
'404':
description: Unknown server, or the folder does not exist.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'409':
description: A file is already at the path and overwrite is not true (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'411':
description: The request has no Content-Length (length_required).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'413':
description: The file is over 64 MiB (too_large).
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'503':
$ref: '#/components/responses/ServiceUnavailable'
'504':
description: The file Job did not finish in time; retry.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
'507':
description: >-
felis-api's staging disk has no room for the upload right now
(upload_staging_full), or the world volume has no room for it
(volume_full); nothing was changed.
content:
application/json:
schema: { $ref: '#/components/schemas/Error' }
# ------------------------------------------------------ users (admin tier) ---- # ------------------------------------------------------ users (admin tier) ----
/api/v1/users: /api/v1/users:
+77 -7
View File
@@ -220,7 +220,7 @@ per-server cooldown → global running cap**. Map the API result:
| HTTP | Code | Cause | Fix | | HTTP | Code | Cause | Fix |
|---|---|---|---| |---|---|---|---|
| `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` | | `403` | `forbidden` | `autostartPolicy=allowlist` and UUID not allowlisted, or `ownerOnly` and caller is not owner | Add the UUID / claim the server / set `autostartPolicy=public` |
| `409` | `maintenance_in_progress` | A restore, backup or file write holds the server's world volume (§3b) | Wait for the Job to finish | | `409` | `maintenance_in_progress` | A restore, backup or file change holds the server's world volume (§3b) | Wait for the Job to finish |
| `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive | | `409` | `world_reclaiming` | The idle reaper is archiving the world (§3b item 3); afterwards the server is released with an empty world | Nothing to wait for; the old world stays in the archive |
| `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown | | `429` | (cooldown) | Wake retried within the 30s per-server `WakeCooldown` | Wait out the cooldown |
| `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap | | `503` | `at_capacity` | Global `MaxRunningServers` cap reached | Stop another server or raise the cap |
@@ -238,11 +238,11 @@ shortly.") lives in the Java plugin and is **[CODE-ONLY]** — the codes it reac
to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so to are produced by the Go-tested `authorizeWakeByUUID` / cooldown limiter, so
grade the two halves separately. grade the two halves separately.
### 3b. Wake, restore, backup or file save refused with `maintenance_in_progress` ### 3b. Wake, restore, backup or file change refused with `maintenance_in_progress`
A server's world volume is ReadWriteOnce, and on a single node RWO lets a game A server's world volume is ReadWriteOnce, and on a single node RWO lets a game
pod and a restore Job mount it side by side. So felis-api serialises them per pod and a restore Job mount it side by side. So felis-api serialises them per
server: a restore, a backup, or a file write takes the world, and until its Job server: a restore, a backup, or a file change takes the world, and until its Job
finishes every wake (panel or join) and every other world operation on that finishes every wake (panel or join) and every other world operation on that
server gets `409 maintenance_in_progress`. File reads and listings never hold server gets `409 maintenance_in_progress`. File reads and listings never hold
it. The operator applies the same rule when `desiredState` is flipped to it. The operator applies the same rule when `desiredState` is flipped to
@@ -253,7 +253,8 @@ What holds the world, in order:
1. An unfinished Job labelled `felis.lolicon.best/server=<name>` with 1. An unfinished Job labelled `felis.lolicon.best/server=<name>` with
`app.kubernetes.io/managed-by` `felis-restore`, `felis-backup`, or `app.kubernetes.io/managed-by` `felis-restore`, `felis-backup`, or
`felis-files` plus `felis.lolicon.best/files-mode=write`: `felis-files` with any `felis.lolicon.best/files-mode` but `list` or `read`
(a save, new file, new folder, rename, delete or upload; §18):
```sh ```sh
kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name> kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name>
@@ -281,7 +282,7 @@ What holds the world, in order:
the lock before it deletes the world volume, keeps the world and retries the the lock before it deletes the world volume, keeps the world and retries the
next day. next day.
A restore, backup or file write refused with `409 not_stopped` although the A restore, backup or file change refused with `409 not_stopped` although the
panel shows `Stopped` means the game pod is still terminating (its shutdown save panel shows `Stopped` means the game pod is still terminating (its shutdown save
can take a while); retry once `kubectl -n minecraft get pods -l can take a while); retry once `kubectl -n minecraft get pods -l
felis.lolicon.best/server=<name>` shows nothing. felis.lolicon.best/server=<name>` shows nothing.
@@ -465,7 +466,7 @@ installer (`sudo bash deploy/bootstrap.sh`) puts the value everywhere. [GO-TESTE
stale. A proxy on another host is left alone: set `service-token` in its stale. A proxy on another host is left alone: set `service-token` in its
`felis-link.properties` to the value in Secret `felis/felis-service-token`, and `felis-link.properties` to the value in Secret `felis/felis-service-token`, and
it takes it within a few seconds. it takes it within a few seconds.
- **forwarding** — a server whose world a backup, restore or file write holds is - **forwarding** — a server whose world a backup, restore or file change holds is
left running and named in the plan and the output; players cannot join it until left running and named in the plan and the output; players cannot join it until
it restarts, so stop and start it from the panel once that finishes. The next it restarts, so stop and start it from the panel once that finishes. The next
installer run restarts the proxy once more (its record of what the proxy was installer run restarts the proxy once more (its record of what the proxy was
@@ -970,7 +971,7 @@ The reap sequence (all [GO-TESTED] hermetically) preserves the world unless a
0. The world must be at rest before it is archived. A server still meant to 0. The world must be at rest before it is archived. A server still meant to
run is told to stop (`desiredState: Stopped`) and left for the next run; one run is told to stop (`desiredState: Stopped`) and left for the next run; one
still stopping, whose game pod still exists, or whose world a restore, still stopping, whose game pod still exists, or whose world a restore,
backup or file write holds is left too. Each of these counts in backup or file change holds is left too. Each of these counts in
`awaiting_stop=` and does not fail the run. Once the server is down, the `awaiting_stop=` and does not fail the run. Once the server is down, the
reaper takes the world's maintenance lock (§3b) and holds it through the reaper takes the world's maintenance lock (§3b) and holds it through the
archive and the volume delete: nothing can start the server or touch its archive and the volume delete: nothing can start the server or touch its
@@ -2990,6 +2991,74 @@ for 10 seconds (the Free plan's limits).
--- ---
## 18. Server files: a change or an upload is refused
The panel's Files page is for the server's owner or an admin, and only while
the server is fully stopped. Each call runs a one-shot `felis files` Job in the
`minecraft` namespace, labelled `app.kubernetes.io/managed-by=felis-files` and
`felis.lolicon.best/files-mode=<list|read|write|mkdir|delete|rename|upload>`.
A listing or a read holds nothing. Every change (a save, a new file or folder,
a rename, a delete, an upload) holds the world for its Job (§3b), so a wake or a
second change in the meantime gets `409 maintenance_in_progress`. The panel
sends uploads one at a time and greys its other changes until they finish.
| Status | Code | Meaning | What to do |
|---|---|---|---|
| `409` | `not_stopped` | The game pod is still there, usually finishing its shutdown save | Retry once `kubectl -n minecraft get pods -l felis.lolicon.best/server=<name>` shows nothing |
| `409` | `maintenance_in_progress` | Another change, a backup, a restore or the reaper holds the world | §3b |
| `409` | `file_exists` | A new file, new folder, rename, or upload sent without replace found something at the path | Pick another name or clear the path; the panel offers Replace for an upload |
| `409` | `file_changed` | The file changed after the editor read it | The editor offers to load the latest or overwrite it |
| `400` | `bad_path` | The path leaves the world volume (`..`, an absolute path, a link pointing out), or it would move `server.properties`, `config` or `config/paper-global.yml`, or read `config/paper-global.yml` | Those three keep their names: the read path withholds their secrets by name, and `paper-global.yml` holds the proxy forwarding secret every server shares |
| `404` | `not_found` | The path, or a new folder's parent, is gone | Refresh the listing |
| `413` | `too_large` | A read over 1 MiB, a save over 256 KiB, or an upload over 64 MiB | Upload a large file whole instead of editing it |
| `411` | `length_required` | An upload without `Content-Length` (a chunked body) | Upload from the panel, or with `curl -T`, which sends the length |
| `400` | `upload_incomplete` | The body ended before its declared length | Retry; nothing was changed |
| `507` | `upload_staging_full` | Staging this upload would leave felis-api's staging filesystem under 10% free | Free space on the uploads volume |
| `507` | `volume_full` | The world volume ran out of space; the old file is left as it was | Delete files the server no longer needs, or grow its volume |
| `504` | `files_timeout` | felis-api stopped waiting after 90 s | See below: the Job may still finish |
| `503` | `files_unavailable` | felis-api runs without the file Job runner, or (for an upload) without a staging directory or its internal address | Check felis-api's startup log |
**An upload travels in two legs.** The browser sends the body to felis-api,
which stages it under `/var/lib/felis/uploads/.file-staging` on the uploads
volume (`felis-file-staging` in the pod's temp directory when that volume is not
mounted). The Job then fetches it once from felis-api's internal face,
`http://felis-api-internal.felis.svc.cluster.local:8081/api/v1/internal/file-uploads/<id>`,
with a one-time token, checks the size and SHA-256, and lands it. The staged
copy is deleted once the Job has answered, and a felis-api restart empties the
directory. A Job that cannot fetch its upload, or fetches bytes that do not
match, fails and leaves the target as it was; the panel shows a server error
it can retry. The Job's log names the cause:
```sh
kubectl -n minecraft get jobs -l felis.lolicon.best/server=<name>,app.kubernetes.io/managed-by=felis-files
kubectl -n minecraft logs job/<job>
```
**After `files_timeout`** the Job runs on to its own two-minute deadline and may
still land the change. It keeps holding the world until it ends, so the next
change waits on it with `maintenance_in_progress`; refresh the listing once it
is gone to see whether the change landed. A Job is kept for two minutes after
it ends, with its log.
Every change is audited as `file.write`, `file.mkdir`, `file.delete`,
`file.rename` (with `to`) or `file.upload` (with `size_bytes`, `sha256` and
`overwrite`), with `server_name` set to `<server>:<path>`:
```sh
sudo k3s kubectl -n felis exec deploy/felis-postgres -c postgres -- psql -U postgres felis -c "
SELECT created_at, actor, action, server_name, payload
FROM audit_logs WHERE action LIKE 'file.%' ORDER BY created_at DESC LIMIT 20;"
```
[GO-TESTED: `internal/fileedit`, `handlers_files_test.go`, `cmd/felis/files_test.go`,
`internal/maintenance`.] [VM-TESTED: a pod labelled as a files Job in `minecraft`
reaches `felis-api-internal:8081`; a 256 KiB save's content, split across six
variables, lands byte for byte through the real binary, where one 140 KB variable
fails with `argument list too long`. An upload through the API, both legs end to
end, has not been run on a cluster.]
---
## Quick reference: symptom → section ## Quick reference: symptom → section
| Symptom | Section | | Symptom | Section |
@@ -3033,3 +3102,4 @@ for 10 seconds (the Free plan's limits).
| `FelisAuditWriteFailing` | §17 | | `FelisAuditWriteFailing` | §17 |
| `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 | | `felis breakGlass` sends no code / shows `Root override`; `otp_skipped` in the audit | §17 |
| How long sessions, codes and audit rows are kept; export audit rows | §17 | | How long sessions, codes and audit rows are kept; export audit rows | §17 |
| Files page: a change or upload refused (`file_exists`, `bad_path`, `too_large`, `upload_staging_full`, `volume_full`, `files_timeout`) | §18 |
+25 -9
View File
@@ -24,6 +24,7 @@ import (
"time" "time"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
) )
// API holds the dependencies shared by every handler. // API holds the dependencies shared by every handler.
@@ -84,13 +85,18 @@ type API struct {
// something has to start the restore once the snapshot is done. // something has to start the restore once the snapshot is done.
RestoreChains RestoreChains RestoreChains RestoreChains
// Files is the server file editor (list / read / write a file in a stopped // Files is the server file manager (list, read, write, make a folder, delete,
// server's world volume — the "one wrong line in server.properties" repair). // rename and upload inside a stopped server's world volume).
// Like Restorer and Backuper it is optional: when nil the file routes report // Like Restorer and Backuper it is optional: when nil the file routes report
// 503, so the owner-or-admin and stopped gates are exercised before the // 503, so the owner-or-admin and stopped gates are exercised before the
// file-Job executor is wired. Unlike them its calls are synchronous, because // file-Job executor is wired. Unlike them its calls are synchronous, because
// the caller wants the listing or the bytes back, not a 202. // the caller wants the listing or the bytes back, not a 202.
Files FileEditor Files FileEditor
// FileStage holds uploads until the Job landing them fetches them, and
// InternalBaseURL is where that Job reaches felis-api's internal face to do so
// (handleUploadFile). Uploads report 503 unless both are set.
FileStage *fileedit.Stage
InternalBaseURL string
// Submissions is the user-modpack approval lane (a user-directed extension over // Submissions is the user-modpack approval lane (a user-directed extension over
// the §16 build subsystem; see internal/submit). It is optional: when // the §16 build subsystem; see internal/submit). It is optional: when
@@ -443,6 +449,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// snapshot a stopped world while the API is alive. Service-token auth (no // snapshot a stopped world while the API is alive. Service-token auth (no
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate. // Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup}, {Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
// A file upload's staged bytes, fetched once by the Job landing them. Public
// because that Job holds no service token; the one-time bearer token minted
// with the upload is the check (handlers_files.go).
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
} }
} }
@@ -542,13 +553,14 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/jobs", h: a.handleServerJobs}, {Method: "GET", Pattern: "/api/v1/servers/{name}/jobs", h: a.handleServerJobs},
{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup}, {Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
{Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow}, {Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow},
// Server file editor: list / read / write a file in a STOPPED server's world // Server file manager: list, read, write, make a folder, delete, rename and
// volume (handlers_files.go). App-tier, exactly like the backup pair above and // upload in a STOPPED server's world volume (handlers_files.go). App-tier,
// for the same reason — every route gates on owner-or-admin inside the handler, // exactly like the backup pair above and for the same reason — every route
// so an owner repairs their own broken server without an admin's Zero-Trust // gates on owner-or-admin inside the handler, so an owner repairs their own
// path. The path travels as ?path= rather than a segment because a file path // broken server without an admin's Zero-Trust path. The path travels as ?path=
// contains '/' (the same reason DELETE /images takes ?ref=). {name}/files is // rather than a segment because a file path contains '/' (the same reason
// the directory face; {name}/file is the single-file face. // DELETE /images takes ?ref=). {name}/files is the directory face; {name}/file
// is the single-file face.
// //
// "Config editor" undersells the surface, so be precise about what app-tier // "Config editor" undersells the surface, so be precise about what app-tier
// now reaches: the mount is the server's WHOLE working directory, not a // now reaches: the mount is the server's WHOLE working directory, not a
@@ -560,6 +572,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/files", h: a.handleListFiles}, {Method: "GET", Pattern: "/api/v1/servers/{name}/files", h: a.handleListFiles},
{Method: "GET", Pattern: "/api/v1/servers/{name}/file", h: a.handleReadFile}, {Method: "GET", Pattern: "/api/v1/servers/{name}/file", h: a.handleReadFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/file", h: a.handleWriteFile}, {Method: "PUT", Pattern: "/api/v1/servers/{name}/file", h: a.handleWriteFile},
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/file", h: a.handleDeleteFile},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile},
// Account linking (spec §10), web side: /start reports link status (it is the // Account linking (spec §10), web side: /start reports link status (it is the
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds // pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
// the account. App-tier, not admin — linking your own account is an ordinary // the account. App-tier, not admin — linking your own account is an ordinary
+266 -19
View File
@@ -3,8 +3,11 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"io"
"net/http" "net/http"
"regexp" "regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
@@ -32,15 +35,22 @@ import (
// parallel type on this side of the seam. // parallel type on this side of the seam.
// //
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict / // It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507. // ErrNoSpace / ErrExists, which writeFileEditError maps to 404 / 400 / 413 / 409 /
// 507 / 409.
// //
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash // Read and Write return the file's SHA-256 (hex); Upload lands exactly the bytes
// a client read the file at; when set, a file that changed since is refused with // src describes or fails. Write's expect is the
// ErrConflict instead of being overwritten. // hash a client read the file at; when set, a file that changed since is refused
// with ErrConflict instead of being overwritten. createOnly and a false overwrite
// refuse an existing path with ErrExists.
type FileEditor interface { type FileEditor interface {
List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error) List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error)
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error) Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err error) Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (sha256 string, err error)
Mkdir(ctx context.Context, server, path string) error
Delete(ctx context.Context, server, path string) error
Rename(ctx context.Context, server, path, to string) error
Upload(ctx context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error
} }
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so // writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
@@ -58,10 +68,14 @@ type FileEditor interface {
// ExpectSHA256 is optional. The panel always sends the hash its read returned, // ExpectSHA256 is optional. The panel always sends the hash its read returned,
// so a save over a file someone else changed in the meantime answers 409 // so a save over a file someone else changed in the meantime answers 409
// file_changed; omitting it (a script, or "overwrite anyway") writes // file_changed; omitting it (a script, or "overwrite anyway") writes
// unconditionally. // unconditionally. CreateOnly is the panel's "new file": the write lands only if
// nothing is at the path yet (409 file_exists otherwise), so it can never
// truncate a file the caller did not know was there. The two cannot be combined —
// one says the file exists, the other that it must not.
type writeFileRequest struct { type writeFileRequest struct {
Content *[]byte `json:"content"` Content *[]byte `json:"content"`
ExpectSHA256 string `json:"expect_sha256,omitempty"` ExpectSHA256 string `json:"expect_sha256,omitempty"`
CreateOnly bool `json:"create_only,omitempty"`
} }
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's // handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
@@ -105,10 +119,8 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
path := r.URL.Query().Get("path") path, ok := requirePath(w, r)
if path == "" { if !ok {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return return
} }
@@ -140,10 +152,8 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
if !ok { if !ok {
return return
} }
path := r.URL.Query().Get("path") path, ok := requirePath(w, r)
if path == "" { if !ok {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return return
} }
@@ -173,6 +183,11 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned")) "expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
return return
} }
if body.CreateOnly && body.ExpectSHA256 != "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"create_only and expect_sha256 cannot be combined"))
return
}
// A write holds the world volume for its Job's lifetime (internal/maintenance); // A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server // reads and listings do not, since a read-only mount cannot hurt a server
@@ -183,19 +198,249 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
} }
defer release() defer release()
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256) sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256, body.CreateOnly)
if err != nil { if err != nil {
writeFileEditError(w, r, err) writeFileEditError(w, r, err)
return return
} }
a.audit(r, "file.write", name+":"+path) a.auditFile(r, "file.write", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum}) writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
} }
// requirePath reads the required ?path= query parameter, answering 400 when it
// is absent.
func requirePath(w http.ResponseWriter, r *http.Request) (string, bool) {
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return "", false
}
return path, true
}
// handleMkdir serves POST /api/v1/servers/{name}/files/mkdir?path=… — make one
// folder. Its parent must exist (404 otherwise) and nothing may be at the path yet
// (409 file_exists). Like every file change it holds the world lock and is
// audited.
func (a *API) handleMkdir(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Mkdir(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.mkdir", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "created"})
}
// handleDeleteFile serves DELETE /api/v1/servers/{name}/file?path=… — delete a
// file, a symlink (never what it points at), or a folder with everything in it.
// The world root itself is refused (400 bad_path). The panel confirms first; this
// route does not, because a script that says DELETE means it.
func (a *API) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Delete(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.delete", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "deleted"})
}
// renameFileRequest is the POST /servers/{name}/files/rename body: the new path,
// relative to the world root like ?path=.
type renameFileRequest struct {
To string `json:"to"`
}
// handleRenameFile serves POST /api/v1/servers/{name}/files/rename?path=… — move
// a file or folder to body.to. It never replaces: an existing destination is 409
// file_exists. server.properties, config/paper-global.yml and config/ cannot be
// moved (400 bad_path), since under another name the read path would no longer
// know to withhold their secrets.
func (a *API) handleRenameFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
var body renameFileRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if body.To == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "the to field is required"))
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Rename(r.Context(), name, path, body.To); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.rename", name, path, map[string]any{"to": body.To})
writeJSON(w, http.StatusOK, map[string]any{"path": path, "to": body.To, "status": "renamed"})
}
// handleUploadFile serves PUT /api/v1/servers/{name}/files/upload?path=… — land
// the raw request body as a file, up to fileedit.MaxUploadBytes. An existing file
// is 409 file_exists unless ?overwrite=true.
//
// The body is staged on felis-api's disk first (fileedit.Stage) and fetched from
// there by the Job, on the internal face, with a one-time token: it fits in
// neither a Job spec nor an environment. The world lock is taken only once the
// body has arrived, so a slow upload does not hold off a backup; the stopped gate
// ran before the body was read and the lock re-checks that nothing started since.
//
// Content-Length is required (411 length_required): the stage reserves room for
// the declared size before a byte is written, and a size promised up front is
// what lets a short body be told from a whole one.
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
if a.FileStage == nil || a.InternalBaseURL == "" {
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
"uploads are not configured"))
return
}
if r.ContentLength < 0 {
writeError(w, r, newError(http.StatusLengthRequired, "length_required",
"an upload needs a Content-Length"))
return
}
if r.ContentLength > fileedit.MaxUploadBytes {
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large",
"the file is %d bytes; uploads are at most %d", r.ContentLength, fileedit.MaxUploadBytes))
return
}
overwrite := r.URL.Query().Get("overwrite") == "true"
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength)
switch {
case errors.Is(err, fileedit.ErrStageFull):
writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full",
"felis has no room to take this upload right now; try again later or ask an admin"))
return
case errors.Is(err, fileedit.ErrShortUpload):
writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete",
"the upload ended before all %d bytes arrived", r.ContentLength))
return
case err != nil:
writeError(w, r, err)
return
}
defer drop()
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
err = a.Files.Upload(r.Context(), name, path, fileedit.UploadSource{
URL: a.InternalBaseURL + "/api/v1/internal/file-uploads/" + staged.ID,
Token: staged.Token,
Size: staged.Size,
SHA256: staged.SHA256,
}, overwrite)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.upload", name, path, map[string]any{
"size_bytes": staged.Size, "sha256": staged.SHA256, "overwrite": overwrite,
})
writeJSON(w, http.StatusOK, map[string]any{
"path": path, "status": "uploaded", "sha256": staged.SHA256, "size": staged.Size,
})
}
// handleInternalFileUpload serves GET /api/v1/internal/file-uploads/{id} — the
// staged bytes of one upload, to the one Job created to land them. It is Public on
// the internal face: the Job holds no service token (it holds no credential at
// all), so the bearer token minted with the upload is the whole check, and it
// opens that upload once. An unknown id, a wrong token and a spent one are the
// same 404, so the route answers nothing about which uploads exist.
func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if a.FileStage == nil || !ok {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
f, size, err := a.FileStage.Open(r.PathValue("id"), token)
if errors.Is(err, fileedit.ErrNotStaged) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
if err != nil {
writeError(w, r, err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
// auditFile records a file change. The target is "<server>:<path>", as file.write
// has always recorded it; extra, when set, is the payload.
func (a *API) auditFile(r *http.Request, action, server, path string, extra map[string]any) {
p := principalFromContext(r.Context())
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: server + ":" + path}
if p != nil {
e.ActorUserID = p.UserID
}
if extra != nil {
e.Payload = auditPayload(extra)
}
a.auditEntry(r, e)
}
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`) var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// authorizeFileOp is the shared front half of all three file handlers — the gate // authorizeFileOp is the shared front half of every file handler — the gate
// that decides whether this caller may touch this server's world at all. It // that decides whether this caller may touch this server's world at all. It
// mirrors the backup/restore gate step for step, because it is guarding the same // mirrors the backup/restore gate step for step, because it is guarding the same
// resource under the same physical constraint: // resource under the same physical constraint:
@@ -211,7 +456,7 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// silently fails to mount // silently fails to mount
// ⑤ the FileEditor must be wired, else 503 // ⑤ the FileEditor must be wired, else 503
// //
// Single-sourcing it is what keeps the three faces from drifting: a read path that // Single-sourcing it is what keeps the handlers from drifting: a read path that
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod // forgot the stopped gate would not merely fail, it would hang waiting for a Pod
// that can never be scheduled. // that can never be scheduled.
// //
@@ -270,7 +515,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
return name, true return name, true
} }
// writeFileEditError maps executor errors onto HTTP status codes. The three // writeFileEditError maps executor errors onto HTTP status codes. The
// sentinels are caller-fault and get precise answers; a timeout is reported as 504 // sentinels are caller-fault and get precise answers; a timeout is reported as 504
// so the caller knows to retry rather than believing the edit was rejected; and // so the caller knows to retry rather than believing the edit was rejected; and
// anything else collapses to a 500 by writeError, so no cluster detail leaks. // anything else collapses to a 500 by writeError, so no cluster detail leaks.
@@ -291,6 +536,8 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error())) writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
case errors.Is(err, fileedit.ErrNoSpace): case errors.Is(err, fileedit.ErrNoSpace):
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error())) writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, fileedit.ErrExists):
writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error()))
case errors.Is(err, context.DeadlineExceeded): case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout", writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly")) "the file operation did not finish in time; retry shortly"))
+469 -55
View File
@@ -2,14 +2,21 @@ package api
import ( import (
"context" "context"
"crypto/sha256"
"encoding/hex"
"encoding/json" "encoding/json"
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest"
"os"
"reflect"
"strconv"
"strings" "strings"
"testing" "testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit" "felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
) )
// fakeFileEditor records what the handlers ask the executor to do and returns // fakeFileEditor records what the handlers ask the executor to do and returns
@@ -20,10 +27,18 @@ type fakeFileEditor struct {
err error err error
calls int calls int
gotOp string
gotServer string gotServer string
gotPath string gotPath string
gotContent []byte gotContent []byte
gotExpect string gotExpect string
gotCreateOnly bool
gotTo string
gotSource fileedit.UploadSource
gotOverwrite bool
// onUpload, when set, runs inside Upload the way the real Job fetches the
// staged bytes while the handler waits.
onUpload func(fileedit.UploadSource)
entries []fileedit.Entry entries []fileedit.Entry
truncated bool truncated bool
@@ -43,19 +58,64 @@ func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, s
return f.content, f.sum, f.err return f.content, f.sum, f.err
} }
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) { func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string, createOnly bool) (string, error) {
f.calls++ f.calls++
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect f.gotOp = fileedit.OpWrite
f.gotServer, f.gotPath, f.gotContent, f.gotExpect, f.gotCreateOnly = server, path, content, expect, createOnly
return f.sum, f.err return f.sum, f.err
} }
func (f *fakeFileEditor) Mkdir(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpMkdir, server, path
return f.err
}
func (f *fakeFileEditor) Delete(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpDelete, server, path
return f.err
}
func (f *fakeFileEditor) Rename(_ context.Context, server, path, to string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotTo = fileedit.OpRename, server, path, to
return f.err
}
func (f *fakeFileEditor) Upload(_ context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotSource, f.gotOverwrite = fileedit.OpUpload, server, path, src, overwrite
if f.onUpload != nil {
f.onUpload(src)
}
return f.err
}
// fileRouteHeader is the Content-Type a file route's body goes with: raw bytes
// for an upload, JSON for any other body.
func fileRouteHeader(name, body string) map[string]string {
switch {
case name == "upload":
return ctHeader("application/octet-stream")
case body != "":
return jsonHeader
}
return nil
}
// testSum is a well-formed sha256 hex digest for the fake to hand out. // testSum is a well-formed sha256 hex digest for the fake to hand out.
var testSum = strings.Repeat("a", 64) var testSum = strings.Repeat("a", 64)
// mkFiles builds an API whose "survival" server is STOPPED and owned by owner1, // mkFiles builds an API whose "survival" server is STOPPED and owned by owner1,
// with a wired fakeFileEditor — the state in which every file operation is // with a wired fakeFileEditor — the state in which every file operation is
// permitted, so each subtest changes exactly the one thing it is about. // permitted, so each subtest changes exactly the one thing it is about.
func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) { //
// Uploads stage in a per-test directory. MinFree is near zero because the
// staging floor is fileedit's to test, and the machine running the tests may
// well have less than 10% of its disk free.
func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
t.Helper()
repo := newFakeRepo() repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
cl := newFakeCluster() cl := newFakeCluster()
@@ -64,13 +124,15 @@ func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
files := &fakeFileEditor{} files := &fakeFileEditor{}
api := newTestAPI(repo, cl) api := newTestAPI(repo, cl)
api.Files = files api.Files = files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
return api, repo, cl, files return api, repo, cl, files
} }
// TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world // TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world
// PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it // PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it
// right beside a running server, and a write lands under a live world that the // right beside a running server, and a write lands under a live world that the
// server's next save overwrites or tears. Every one of the three routes // server's next save overwrites or tears. Every route
// must therefore refuse a non-stopped server with 409 not_stopped BEFORE reaching // must therefore refuse a non-stopped server with 409 not_stopped BEFORE reaching
// the executor, which is why each asserts calls == 0 as well as the status. // the executor, which is why each asserts calls == 0 as well as the status.
func TestFileEditorStoppedGate(t *testing.T) { func TestFileEditorStoppedGate(t *testing.T) {
@@ -85,6 +147,10 @@ func TestFileEditorStoppedGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, {"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
} }
// Both non-stopped shapes matter and they are different states: a server that is // Both non-stopped shapes matter and they are different states: a server that is
@@ -103,16 +169,12 @@ func TestFileEditorStoppedGate(t *testing.T) {
for _, rt := range routes { for _, rt := range routes {
for _, st := range states { for _, st := range states {
t.Run(fmt.Sprintf("%s on a %s server -> 409 not_stopped", rt.name, st.name), func(t *testing.T) { t.Run(fmt.Sprintf("%s on a %s server -> 409 not_stopped", rt.name, st.name), func(t *testing.T) {
api, _, cl, files := mkFiles() api, _, cl, files := mkFiles(t)
cl.byName["survival"].Ready = st.ready cl.byName["survival"].Ready = st.ready
cl.byName["survival"].DesiredState = string(st.desiredState) cl.byName["survival"].DesiredState = string(st.desiredState)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
var hdr map[string]string w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" { if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String()) t.Fatalf("code = %d body %s", w.Code, w.Body.String())
} }
@@ -128,7 +190,7 @@ func TestFileEditorStoppedGate(t *testing.T) {
// world PVC (never started, or already reaped) has no claim for the Job to mount, // world PVC (never started, or already reaped) has no claim for the Job to mount,
// so its Pod would sit Pending until the executor's wait timed out — a 90s hang // so its Pod would sit Pending until the executor's wait timed out — a 90s hang
// and a misleading 504 files_timeout for a request that is knowably impossible. // and a misleading 504 files_timeout for a request that is knowably impossible.
// All three routes must refuse BEFORE creating a Job, with the same specific 409 // Every route must refuse BEFORE creating a Job, with the same specific 409
// the backup/restore faces use. // the backup/restore faces use.
func TestFileEditorWorldVolumeGate(t *testing.T) { func TestFileEditorWorldVolumeGate(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"} owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -142,19 +204,19 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, {"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
} }
for _, rt := range routes { for _, rt := range routes {
t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) { t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) {
api, _, cl, files := mkFiles() api, _, cl, files := mkFiles(t)
cl.noWorld["survival"] = true cl.noWorld["survival"] = true
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
var hdr map[string]string w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" { if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String()) t.Fatalf("code = %d body %s", w.Code, w.Body.String())
} }
@@ -166,8 +228,8 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
} }
// TestFileEditorAuthorization pins who may touch a world's files. It is the same // TestFileEditorAuthorization pins who may touch a world's files. It is the same
// owner-or-admin rule the backup routes enforce, and it must hold on all three // owner-or-admin rule the backup routes enforce, and it must hold on every
// routes — a read-only route leaking another owner's config (an RCON password // route — a read-only route leaking another owner's config (an RCON password
// lives in server.properties) would be as bad as an unauthorized write. // lives in server.properties) would be as bad as an unauthorized write.
func TestFileEditorAuthorization(t *testing.T) { func TestFileEditorAuthorization(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"} owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -183,20 +245,17 @@ func TestFileEditorAuthorization(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files", ""}, {"list", "GET", "/api/v1/servers/survival/files", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
} {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
hdrFor := func(body string) map[string]string { {"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
if body != "" { {"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
return jsonHeader
}
return nil
} }
for _, rt := range routes { for _, rt := range routes {
t.Run(rt.name+": non-owner -> 403, executor untouched", func(t *testing.T) { t.Run(rt.name+": non-owner -> 403, executor untouched", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: stranger} api.External = staticExternal{p: stranger}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body)) w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden { if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
} }
@@ -206,9 +265,9 @@ func TestFileEditorAuthorization(t *testing.T) {
}) })
t.Run(rt.name+": owner -> allowed", func(t *testing.T) { t.Run(rt.name+": owner -> allowed", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body)) w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
} }
@@ -218,10 +277,10 @@ func TestFileEditorAuthorization(t *testing.T) {
}) })
t.Run(rt.name+": admin on someone else's server -> allowed", func(t *testing.T) { t.Run(rt.name+": admin on someone else's server -> allowed", func(t *testing.T) {
api, repo, _, files := mkFiles() api, repo, _, files := mkFiles(t)
repo.byName["survival"].OwnerID = "someone-else" repo.byName["survival"].OwnerID = "someone-else"
api.External = staticExternal{p: admin} api.External = staticExternal{p: admin}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body)) w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
} }
@@ -231,40 +290,40 @@ func TestFileEditorAuthorization(t *testing.T) {
}) })
t.Run(rt.name+": unowned server -> 403 for a plain user", func(t *testing.T) { t.Run(rt.name+": unowned server -> 403 for a plain user", func(t *testing.T) {
api, repo, _, _ := mkFiles() api, repo, _, _ := mkFiles(t)
repo.byName["survival"].OwnerID = "" // released world repo.byName["survival"].OwnerID = "" // released world
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body)) w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden { if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
} }
}) })
t.Run(rt.name+": unknown server -> 404", func(t *testing.T) { t.Run(rt.name+": unknown server -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles() api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "missing", 1), rt.body, hdrFor(rt.body)) strings.Replace(rt.path, "survival", "missing", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusNotFound { if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String()) t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
} }
}) })
t.Run(rt.name+": invalid server name -> 400 bad_name", func(t *testing.T) { t.Run(rt.name+": invalid server name -> 400 bad_name", func(t *testing.T) {
api, _, _, _ := mkFiles() api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "X", 1), rt.body, hdrFor(rt.body)) strings.Replace(rt.path, "survival", "X", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" { if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String()) t.Fatalf("code = %d body %s", w.Code, w.Body.String())
} }
}) })
t.Run(rt.name+": nil FileEditor -> 503 files_unavailable", func(t *testing.T) { t.Run(rt.name+": nil FileEditor -> 503 files_unavailable", func(t *testing.T) {
api, _, _, _ := mkFiles() api, _, _, _ := mkFiles(t)
api.Files = nil api.Files = nil
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body)) w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" { if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String()) t.Fatalf("code = %d body %s", w.Code, w.Body.String())
} }
@@ -278,7 +337,7 @@ func TestFileEditorHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"} owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("list passes the path through and returns entries", func(t *testing.T) { t.Run("list passes the path through and returns entries", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}} files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}}
files.truncated = true files.truncated = true
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
@@ -304,7 +363,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("list without a path lists the world root", func(t *testing.T) { t.Run("list without a path lists the world root", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil) w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
if w.Code != http.StatusOK { if w.Code != http.StatusOK {
@@ -316,7 +375,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("read returns base64 content and its hash", func(t *testing.T) { t.Run("read returns base64 content and its hash", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
files.content = []byte("motd=hello\n") files.content = []byte("motd=hello\n")
files.sum = testSum files.sum = testSum
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
@@ -339,7 +398,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("read without a path -> 400", func(t *testing.T) { t.Run("read without a path -> 400", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file", "", nil) w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file", "", nil)
if w.Code != http.StatusBadRequest { if w.Code != http.StatusBadRequest {
@@ -351,7 +410,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("write decodes content, audits, and answers 200", func(t *testing.T) { t.Run("write decodes content, audits, and answers 200", func(t *testing.T) {
api, repo, _, files := mkFiles() api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -369,7 +428,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) { t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
files.sum = strings.Repeat("b", 64) files.sum = strings.Repeat("b", 64)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -390,7 +449,7 @@ func TestFileEditorHandlers(t *testing.T) {
t.Run("a malformed expected hash -> 400 before the executor", func(t *testing.T) { t.Run("a malformed expected hash -> 400 before the executor", func(t *testing.T) {
for _, bad := range []string{"abc", strings.Repeat("A", 64), strings.Repeat("a", 63) + " ", "--op=list"} { for _, bad := range []string{"abc", strings.Repeat("A", 64), strings.Repeat("a", 63) + " ", "--op=list"} {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
body, _ := json.Marshal(map[string]any{"content": []byte("hi"), "expect_sha256": bad}) body, _ := json.Marshal(map[string]any{"content": []byte("hi"), "expect_sha256": bad})
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -402,7 +461,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) { t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
api, repo, _, files := mkFiles() api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict) files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -416,7 +475,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("reads are not audited", func(t *testing.T) { t.Run("reads are not audited", func(t *testing.T) {
api, repo, _, _ := mkFiles() api, repo, _, _ := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil) do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil) do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -426,7 +485,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("oversized write -> 413 before the executor", func(t *testing.T) { t.Run("oversized write -> 413 before the executor", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
// base64 of MaxWriteBytes+1 zero bytes, built as a JSON body. // base64 of MaxWriteBytes+1 zero bytes, built as a JSON body.
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes+1))}) body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes+1))})
@@ -450,7 +509,7 @@ func TestFileEditorHandlers(t *testing.T) {
// because a deliberate truncate is a real edit; only the OMISSION is refused. // because a deliberate truncate is a real edit; only the OMISSION is refused.
t.Run("a write with no content field -> 400, never a truncate", func(t *testing.T) { t.Run("a write with no content field -> 400, never a truncate", func(t *testing.T) {
for _, body := range []string{`{}`, `{"content":null}`} { for _, body := range []string{`{}`, `{"content":null}`} {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
body, jsonHeader) body, jsonHeader)
@@ -464,7 +523,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("an explicit empty content is a legitimate truncate", func(t *testing.T) { t.Run("an explicit empty content is a legitimate truncate", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":""}`, jsonHeader) `{"content":""}`, jsonHeader)
@@ -478,7 +537,7 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
t.Run("a write at exactly the limit is allowed", func(t *testing.T) { t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))}) body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))})
if err != nil { if err != nil {
@@ -495,6 +554,360 @@ func TestFileEditorHandlers(t *testing.T) {
}) })
} }
// fileAnswer decodes a file route's JSON answer for an exact comparison.
func fileAnswer(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
return m
}
// onlyAudit asserts the request wrote exactly one audit row, by owner1, with
// this action, target and payload ("" for none).
func onlyAudit(t *testing.T, repo *fakeRepo, action, target, payload string) {
t.Helper()
if len(repo.audits) != 1 {
t.Fatalf("audits = %+v, want exactly one %s", repo.audits, action)
}
a := repo.audits[0]
if a.Action != action || a.ServerName != target || a.Actor != "[email protected]" ||
a.ActorUserID != "owner1" || string(a.Payload) != payload {
t.Fatalf("audit = %+v (payload %s), want %s on %s with payload %q", a, a.Payload, action, target, payload)
}
}
// TestFileManagerHandlers covers the routes that change a world's file tree
// beyond a save: what each hands the executor, what it answers, what it audits.
func TestFileManagerHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("mkdir makes the folder and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/mkdir?path=plugins/Essentials", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpMkdir || files.gotPath != "plugins/Essentials" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/Essentials", "status": "created"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.mkdir", "survival:plugins/Essentials", "")
})
t.Run("delete removes the path and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "DELETE", "/api/v1/servers/survival/file?path=plugins/old.jar", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpDelete || files.gotPath != "plugins/old.jar" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/old.jar", "status": "deleted"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.delete", "survival:plugins/old.jar", "")
})
t.Run("rename passes the destination and audits both names", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=plugins/a.jar",
`{"to":"plugins/disabled/a.jar"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpRename || files.gotPath != "plugins/a.jar" || files.gotTo != "plugins/disabled/a.jar" {
t.Fatalf("executor saw %d calls, op %q, %q -> %q", files.calls, files.gotOp, files.gotPath, files.gotTo)
}
want := map[string]any{"path": "plugins/a.jar", "to": "plugins/disabled/a.jar", "status": "renamed"}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.rename", "survival:plugins/a.jar", `{"to":"plugins/disabled/a.jar"}`)
})
t.Run("rename without a destination -> 400 before the executor", func(t *testing.T) {
for _, body := range []string{`{}`, `{"to":""}`} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=a.txt", body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("body %s: code = %d calls = %d (%s), want 400 and no Job", body, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a route that changes a path needs the path", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir", ""},
{"DELETE", "/api/v1/servers/survival/file", ""},
{"POST", "/api/v1/servers/survival/files/rename", `{"to":"b.txt"}`},
{"PUT", "/api/v1/servers/survival/files/upload", "bytes"},
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
hdr := jsonHeader
if rt.method == "PUT" {
hdr = ctHeader("application/octet-stream")
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("%s %s: code = %d calls = %d (%s), want 400 and no Job", rt.method, rt.path, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a refused change is not audited", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"DELETE", "/api/v1/servers/survival/file?path=plugins", ""},
{"POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
} {
api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: plugins already exists", fileedit.ErrExists)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("%s %s: code = %d (%s), want 409 file_exists", rt.method, rt.path, w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("%s %s: a refused change was audited: %+v", rt.method, rt.path, repo.audits)
}
}
})
t.Run("create_only reaches the executor", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml",
`{"content":"","create_only":true}`, jsonHeader)
if w.Code != http.StatusOK || !files.gotCreateOnly || files.gotExpect != "" {
t.Fatalf("code = %d, createOnly = %v, expect = %q (%s)", w.Code, files.gotCreateOnly, files.gotExpect, w.Body.String())
}
w = do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, jsonHeader)
if w.Code != http.StatusOK || files.gotCreateOnly {
t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, files.gotCreateOnly)
}
})
t.Run("create_only with an expected hash -> 400", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=a.yml",
`{"content":"","create_only":true,"expect_sha256":"`+testSum+`"}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s), want 400 and no Job", w.Code, files.calls, w.Body.String())
}
})
}
// doUpload sends an upload whose Content-Length is declared, not measured, the
// way a client that streams or lies would send it.
func doUpload(h http.Handler, body string, length int64) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
r.ContentLength = length
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
recordContract(r, body, w)
return w
}
// stageEmpty asserts no staged upload is left on disk.
func stageEmpty(t *testing.T, api *API) {
t.Helper()
left, err := os.ReadDir(api.FileStage.Dir)
if err != nil {
t.Fatalf("read the stage: %v", err)
}
if len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
}
// TestFileUpload drives an upload across both faces: the external PUT stages the
// body, and the executor, standing in for the Job, fetches it from the internal
// face with the token it was handed, as cmd/felis files does.
func TestFileUpload(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
const body = "PK\x03\x04 a plugin jar"
digest := sha256.Sum256([]byte(body))
sum := hex.EncodeToString(digest[:])
const route = "/api/v1/servers/survival/files/upload?path=plugins/x.jar"
octet := ctHeader("application/octet-stream")
t.Run("the Job fetches the body once, with its token alone", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
// Every other internal route wants a service token; the Job has none.
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
prefix := api.InternalBaseURL + "/api/v1/internal/file-uploads/"
var bare, wrong, unschemed, fetched, again *httptest.ResponseRecorder
files.onUpload = func(src fileedit.UploadSource) {
id, ok := strings.CutPrefix(src.URL, prefix)
if !ok || len(id) != 32 {
t.Errorf("source URL %q is not one id under %q", src.URL, prefix)
return
}
h := api.InternalHandler()
at := "/api/v1/internal/file-uploads/" + id
bare = do(h, "GET", at, "", nil)
wrong = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + strings.Repeat("0", len(src.Token))})
unschemed = do(h, "GET", at, "", map[string]string{"Authorization": src.Token})
fetched = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
again = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if fetched == nil {
t.Fatal("the executor never fetched the upload")
}
for name, r := range map[string]*httptest.ResponseRecorder{
"no token": bare, "wrong token": wrong, "the token without Bearer": unschemed, "second fetch": again,
} {
if r.Code != http.StatusNotFound || decodeErr(t, r) != "not_found" {
t.Errorf("%s: code = %d (%s), want 404 not_found", name, r.Code, r.Body.String())
}
}
if fetched.Code != http.StatusOK || fetched.Body.String() != body ||
fetched.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
t.Fatalf("fetch: code = %d, %q, Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length"))
}
if files.gotPath != "plugins/x.jar" || files.gotSource.Size != int64(len(body)) ||
files.gotSource.SHA256 != sum || files.gotOverwrite {
t.Fatalf("executor saw path %q, source %+v, overwrite %v", files.gotPath, files.gotSource, files.gotOverwrite)
}
want := map[string]any{"path": "plugins/x.jar", "status": "uploaded", "sha256": sum, "size": float64(len(body))}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":false,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
stageEmpty(t, api)
})
t.Run("overwrite=true reaches the executor", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", route+"&overwrite=true", body, octet)
if w.Code != http.StatusOK || !files.gotOverwrite {
t.Fatalf("code = %d, overwrite = %v (%s)", w.Code, files.gotOverwrite, w.Body.String())
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":true,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
})
t.Run("a refused upload is not audited and its bytes are dropped", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
files.err = fmt.Errorf("%w: plugins/x.jar already exists", fileedit.ErrExists)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("code = %d (%s), want 409 file_exists", w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("a refused upload was audited: %+v", repo.audits)
}
stageEmpty(t, api)
})
t.Run("a lock that cannot be taken drops the staged bytes", func(t *testing.T) {
api, _, cl, files := mkFiles(t)
api.External = staticExternal{p: owner}
cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindBackup}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("no Content-Length -> 411", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, -1)
if w.Code != http.StatusLengthRequired || decodeErr(t, w) != "length_required" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a declared size over the cap -> 413 before a byte is staged", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes+1)
if w.Code != http.StatusRequestEntityTooLarge || decodeErr(t, w) != "too_large" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
// Staged, and so short: the body is a few bytes of a declared 64 MiB.
t.Run("a declared size at the cap is taken", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a body shorter than its Content-Length -> 400 upload_incomplete", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, int64(len(body))+1)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("a staging disk at its floor -> 507", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
api.FileStage.MinFree = 1
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusInsufficientStorage || decodeErr(t, w) != "upload_staging_full" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("uploads not wired -> 503", func(t *testing.T) {
for name, unwire := range map[string]func(*API){
"no stage": func(a *API) { a.FileStage = nil },
"no internal URL": func(a *API) { a.InternalBaseURL = "" },
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
unwire(api)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" || files.calls != 0 {
t.Fatalf("%s: code = %d calls = %d (%s)", name, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("the internal route without a stage -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.FileStage = nil
w := do(api.InternalHandler(), "GET", "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
map[string]string{"Authorization": "Bearer t"})
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
})
}
// TestFileEditorErrorMapping proves each executor sentinel reaches the caller as the // TestFileEditorErrorMapping proves each executor sentinel reaches the caller as the
// right status. The containment refusal mapping to 400 (not 403) is the one worth // right status. The containment refusal mapping to 400 (not 403) is the one worth
// stating: an escaping path is a malformed request, not a permission a caller might // stating: an escaping path is a malformed request, not a permission a caller might
@@ -513,12 +926,13 @@ func TestFileEditorErrorMapping(t *testing.T) {
{"oversized file", fmt.Errorf("%w: nope", fileedit.ErrTooLarge), http.StatusRequestEntityTooLarge, "too_large"}, {"oversized file", fmt.Errorf("%w: nope", fileedit.ErrTooLarge), http.StatusRequestEntityTooLarge, "too_large"},
{"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"}, {"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"},
{"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"}, {"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
{"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"},
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"}, {"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
files.err = tc.err files.err = tc.err
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil) w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -529,7 +943,7 @@ func TestFileEditorErrorMapping(t *testing.T) {
} }
t.Run("an unrecognised executor failure -> 500", func(t *testing.T) { t.Run("an unrecognised executor failure -> 500", func(t *testing.T) {
api, _, _, files := mkFiles() api, _, _, files := mkFiles(t)
files.err = fmt.Errorf("the job pod exploded") files.err = fmt.Errorf("the job pod exploded")
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil) w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
+19 -9
View File
@@ -6,9 +6,11 @@ import (
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices" "slices"
"strings"
"testing" "testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1" "felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance" "felis.lolicon.best/internal/maintenance"
) )
@@ -84,7 +86,8 @@ type maintenanceOp struct {
calls func() int calls func() int
} }
func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) { func maintenanceOps(t *testing.T) (*API, *fakeCluster, []maintenanceOp) {
t.Helper()
repo := newFakeRepo() repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"} repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
repo.backups = []fakeBackup{{view: BackupView{ID: "bk1", ServerName: "survival", repo.backups = []fakeBackup{{view: BackupView{ID: "bk1", ServerName: "survival",
@@ -95,6 +98,8 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
restorer, backuper, files := &fakeRestorer{}, &fakeBackuper{}, &fakeFileEditor{} restorer, backuper, files := &fakeRestorer{}, &fakeBackuper{}, &fakeFileEditor{}
api := newTestAPI(repo, cl) api := newTestAPI(repo, cl)
api.Restorer, api.Backuper, api.Files = restorer, backuper, files api.Restorer, api.Backuper, api.Files = restorer, backuper, files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
api.External = staticExternal{p: &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}} api.External = staticExternal{p: &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}}
return api, cl, []maintenanceOp{ return api, cl, []maintenanceOp{
{"restore", maintenance.KindRestore, "POST", "/api/v1/servers/survival/restore-backup", "", {"restore", maintenance.KindRestore, "POST", "/api/v1/servers/survival/restore-backup", "",
@@ -103,22 +108,27 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
func() int { return backuper.calls }}, func() int { return backuper.calls }},
{"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties", {"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, func() int { return files.calls }}, `{"content":"aGk="}`, func() int { return files.calls }},
{"file mkdir", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/mkdir?path=plugins",
"", func() int { return files.calls }},
{"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar",
"", func() int { return files.calls }},
{"file rename", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/rename?path=a.txt",
`{"to":"b.txt"}`, func() int { return files.calls }},
{"file upload", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar",
"PK-jar-bytes", func() int { return files.calls }},
} }
} }
func (op maintenanceOp) do(api *API) *httptest.ResponseRecorder { func (op maintenanceOp) do(api *API) *httptest.ResponseRecorder {
var hdr map[string]string hdr := fileRouteHeader(strings.TrimPrefix(op.name, "file "), op.body)
if op.body != "" {
hdr = jsonHeader
}
return do(api.ExternalHandler(), op.method, op.path, op.body, hdr) return do(api.ExternalHandler(), op.method, op.path, op.body, hdr)
} }
func TestMaintenanceOpsTakeAndReleaseTheLock(t *testing.T) { func TestMaintenanceOpsTakeAndReleaseTheLock(t *testing.T) {
_, _, ops := maintenanceOps() _, _, ops := maintenanceOps(t)
for i := range ops { for i := range ops {
t.Run(ops[i].name, func(t *testing.T) { t.Run(ops[i].name, func(t *testing.T) {
api, cl, ops := maintenanceOps() api, cl, ops := maintenanceOps(t)
op := ops[i] op := ops[i]
if w := op.do(api); w.Code/100 != 2 { if w := op.do(api); w.Code/100 != 2 {
t.Fatalf("code = %d body %s", w.Code, w.Body.String()) t.Fatalf("code = %d body %s", w.Code, w.Body.String())
@@ -147,11 +157,11 @@ func TestMaintenanceOpsRefusedWhileHeld(t *testing.T) {
// wake won the race. // wake won the race.
{"server not stopped", fmt.Errorf("wrapped: %w", ErrNotStopped), "not_stopped"}, {"server not stopped", fmt.Errorf("wrapped: %w", ErrNotStopped), "not_stopped"},
} }
_, _, ops := maintenanceOps() _, _, ops := maintenanceOps(t)
for i := range ops { for i := range ops {
for _, rf := range refusals { for _, rf := range refusals {
t.Run(ops[i].name+" / "+rf.name, func(t *testing.T) { t.Run(ops[i].name+" / "+rf.name, func(t *testing.T) {
api, cl, ops := maintenanceOps() api, cl, ops := maintenanceOps(t)
op := ops[i] op := ops[i]
cl.maintErr["survival"] = rf.err cl.maintErr["survival"] = rf.err
w := op.do(api) w := op.do(api)
+50
View File
@@ -0,0 +1,50 @@
package fileedit
import (
"encoding/base64"
"fmt"
"strconv"
)
// splitContent is the base64 of content cut into contentChunk-sized parts, the
// values of ContentEnv_0 … ContentEnv_<n-1>. Empty content is zero parts.
func splitContent(content []byte) []string {
enc := base64.StdEncoding.EncodeToString(content)
parts := make([]string, 0, (len(enc)+contentChunk-1)/contentChunk)
for len(enc) > 0 {
n := min(len(enc), contentChunk)
parts = append(parts, enc[:n])
enc = enc[n:]
}
return parts
}
// contentPartEnv names part i of the content.
func contentPartEnv(i int) string { return ContentEnv + "_" + strconv.Itoa(i) }
// ContentFromEnv reassembles a write's content from the environment the Job spec
// set (see ContentEnv). A part count that is missing, not a number or negative,
// or a part that is not set, is an error rather than a shorter file: writing a
// truncated config would be worse than not writing at all. Parts are looked up
// rather than read so an unset one cannot pass as empty; the lookups stop at the
// first one missing, so a count larger than the spec carries costs nothing.
func ContentFromEnv(lookup func(string) (string, bool)) ([]byte, error) {
raw, _ := lookup(ContentPartsEnv)
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return nil, fmt.Errorf("%s=%q is not a part count", ContentPartsEnv, raw)
}
var enc []byte
for i := range n {
part, ok := lookup(contentPartEnv(i))
if !ok {
return nil, fmt.Errorf("%s is not set", contentPartEnv(i))
}
enc = append(enc, part...)
}
content, err := base64.StdEncoding.DecodeString(string(enc))
if err != nil {
return nil, fmt.Errorf("the content is not valid base64: %w", err)
}
return content, nil
}
+94
View File
@@ -0,0 +1,94 @@
package fileedit
import (
"bytes"
"encoding/base64"
"strconv"
"testing"
)
// maxContentParts is how many ContentEnv parts the largest write needs.
var maxContentParts = (base64.StdEncoding.EncodedLen(MaxWriteBytes) + contentChunk - 1) / contentChunk
func mapLookup(env map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
v, ok := env[name]
return v, ok
}
}
// contentEnv is the environment splitContent's parts become on the Job spec.
func contentEnv(content []byte) map[string]string {
parts := splitContent(content)
env := map[string]string{ContentPartsEnv: strconv.Itoa(len(parts))}
for i, p := range parts {
env[contentPartEnv(i)] = p
}
return env
}
// TestContentSplitRoundTrip: whatever the size, the parts reassemble to the
// bytes written, each part fits in contentChunk, and the count is the fewest
// that do.
func TestContentSplitRoundTrip(t *testing.T) {
full := contentChunk / 4 * 3 // bytes whose base64 is exactly one chunk
for _, tc := range []struct {
size, parts int
}{
{0, 0}, {1, 1}, {full, 1}, {full + 1, 2}, {2 * full, 2}, {2*full + 1, 3},
{MaxWriteBytes, maxContentParts},
} {
content := make([]byte, tc.size)
for i := range content {
content[i] = byte(i*31 + 7)
}
parts := splitContent(content)
if len(parts) != tc.parts {
t.Errorf("%d bytes: %d parts, want %d", tc.size, len(parts), tc.parts)
}
for i, p := range parts {
if len(p) == 0 || len(p) > contentChunk {
t.Errorf("%d bytes: part %d is %d chars, want 1..%d", tc.size, i, len(p), contentChunk)
}
}
got, err := ContentFromEnv(mapLookup(contentEnv(content)))
if err != nil || !bytes.Equal(got, content) {
t.Errorf("%d bytes: reassembled %d bytes, %v", tc.size, len(got), err)
}
}
}
// TestContentFromEnvRefusesAnIncompleteSpec: a spec that does not carry the
// whole content is an error. Writing what did arrive would truncate a config.
func TestContentFromEnvRefusesAnIncompleteSpec(t *testing.T) {
two := contentEnv(make([]byte, contentChunk)) // two parts
if two[ContentPartsEnv] != "2" {
t.Fatalf("fixture has %s parts, want 2", two[ContentPartsEnv])
}
withCount := func(n string) map[string]string {
env := map[string]string{ContentPartsEnv: n}
for k, v := range two {
if k != ContentPartsEnv {
env[k] = v
}
}
return env
}
missingPart := withCount("2")
delete(missingPart, contentPartEnv(1))
for name, env := range map[string]map[string]string{
"no count": {},
"empty count": withCount(""),
"count not a number": withCount("two"),
// A negative count would read no parts and write an empty file.
"negative count": withCount("-1"),
"count over the parts set": withCount("3"),
"a part missing": missingPart,
"not base64": {ContentPartsEnv: "1", contentPartEnv(0): "@@@@"},
} {
if got, err := ContentFromEnv(mapLookup(env)); err == nil {
t.Errorf("%s: reassembled %d bytes, want an error", name, len(got))
}
}
}
+83 -37
View File
@@ -1,7 +1,9 @@
// Package fileedit implements the server file editor (list / read / write a file // Package fileedit implements the server file manager: list, read, write, make a
// in a server's world volume), the lever an owner reaches for when a server will // folder, delete, rename and upload inside a server's world volume. It began as
// not boot because one line of server.properties or a plugin's YAML is wrong — // the lever an owner reaches for when a server will not boot because one line of
// the one repair that otherwise requires a human with cluster access. // server.properties or a plugin's YAML is wrong — the one repair that otherwise
// requires a human with cluster access — and also covers the everyday chores: drop
// in a plugin jar, clear out a folder, rename a world.
// //
// felis-api cannot touch a world in-process: the world PVC is ReadWriteOnce and // felis-api cannot touch a world in-process: the world PVC is ReadWriteOnce and
// its lifecycle is owned by the operator's StatefulSet, so the API has nothing to // its lifecycle is owned by the operator's StatefulSet, so the API has nothing to
@@ -26,12 +28,14 @@
// //
// No pods/exec, no pods/portforward, not even pods:get — the least-privilege line // No pods/exec, no pods/portforward, not even pods:get — the least-privilege line
// internal/platform/rbac.go draws and a test asserts. The write direction travels // internal/platform/rbac.go draws and a test asserts. The write direction travels
// the other way, on the Job spec felis-api creates (see ContentEnv). // the other way: an edit on the Job spec felis-api creates (see ContentEnv), an
// upload fetched by the Job from felis-api's internal face (see Stage), because a
// 64 MiB jar fits in neither a Job spec nor an environment.
// //
// The price is latency: every operation is a Pod schedule + image pull, so a // The price is latency: every operation is a Pod schedule + image pull, so a
// listing takes seconds rather than milliseconds. That is inherent to RWO plus a // listing takes seconds rather than milliseconds. That is inherent to RWO plus a
// stopped server, not a property of this transport, and it is why the editor is a // stopped server, not a property of this transport: the file manager works on a
// repair tool rather than a file manager. // stopped server, one operation per Job.
// //
// The Editor depends on the Runner interface, so the orchestration and the error // The Editor depends on the Runner interface, so the orchestration and the error
// mapping are unit-tested against an in-memory fake; the client-go implementation // mapping are unit-tested against an in-memory fake; the client-go implementation
@@ -70,6 +74,9 @@ var (
// ErrNoSpace is a write the world volume had no room for; the file is // ErrNoSpace is a write the world volume had no room for; the file is
// unchanged. // unchanged.
ErrNoSpace = errors.New("fileedit: the world volume is full") ErrNoSpace = errors.New("fileedit: the world volume is full")
// ErrExists is a create, mkdir, rename or upload whose target is already
// there.
ErrExists = errors.New("fileedit: the target already exists")
) )
// Runner is the cluster-side half of one file operation: render and create the // Runner is the cluster-side half of one file operation: render and create the
@@ -187,7 +194,7 @@ type Editor struct {
// List returns one directory's entries, resolved under the server's world root. // List returns one directory's entries, resolved under the server's world root.
// An empty path lists the world root itself. // An empty path lists the world root itself.
func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool, error) { func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool, error) {
res, err := e.run(ctx, server, OpList, path, nil, "") res, err := e.run(ctx, server, JobParams{Op: OpList, Path: path})
if err != nil { if err != nil {
return nil, false, err return nil, false, err
} }
@@ -202,7 +209,7 @@ func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool,
// Read returns a file's bytes, resolved under the server's world root, and the // Read returns a file's bytes, resolved under the server's world root, and the
// SHA-256 of the file as it is on disk — the value to hand back as Write's expect. // SHA-256 of the file as it is on disk — the value to hand back as Write's expect.
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) { func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) {
res, err := e.run(ctx, server, OpRead, path, nil, "") res, err := e.run(ctx, server, JobParams{Op: OpRead, Path: path})
if err != nil { if err != nil {
return nil, "", err return nil, "", err
} }
@@ -217,26 +224,68 @@ func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string,
// Write atomically replaces a file's contents, creating it if absent (but never // Write atomically replaces a file's contents, creating it if absent (but never
// creating parent directories — see the write helper in exec.go), and returns the // creating parent directories — see the write helper in exec.go), and returns the
// new SHA-256. A non-empty expect makes it conditional: ErrConflict if the file no // new SHA-256. A non-empty expect makes it conditional: ErrConflict if the file no
// longer hashes to it. // longer hashes to it. createOnly refuses a path that exists with ErrExists.
func (e *Editor) Write(ctx context.Context, server, path string, content []byte, expect string) (string, error) { func (e *Editor) Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (string, error) {
res, err := e.run(ctx, server, OpWrite, path, content, expect) res, err := e.run(ctx, server, JobParams{
Op: OpWrite, Path: path, Content: content, Expect: expect, CreateOnly: createOnly,
})
if err != nil { if err != nil {
return "", err return "", err
} }
return res.SHA256, nil return res.SHA256, nil
} }
// run is the shared body of all three operations: mint an op id, render the // Mkdir makes one directory; its parent must exist.
// params, run the Job, and translate the Result's code into a sentinel error. func (e *Editor) Mkdir(ctx context.Context, server, path string) error {
_, err := e.run(ctx, server, JobParams{Op: OpMkdir, Path: path})
return err
}
// Delete removes a file, a link, or a directory with everything in it.
func (e *Editor) Delete(ctx context.Context, server, path string) error {
_, err := e.run(ctx, server, JobParams{Op: OpDelete, Path: path})
return err
}
// Rename moves path to to. It never replaces an existing destination.
func (e *Editor) Rename(ctx context.Context, server, path, to string) error {
_, err := e.run(ctx, server, JobParams{Op: OpRename, Path: path, To: to})
return err
}
// UploadSource is where an upload Job fetches its bytes: a one-time URL on
// felis-api's internal face and the token that opens it (see Stage), plus the size
// and SHA-256 the fetched bytes must match.
type UploadSource struct {
URL string
Token string
Size int64
SHA256 string
}
// Upload lands the staged bytes at path. The Job refuses bytes that do not match
// src.Size and src.SHA256, so a nil error means exactly those landed. overwrite
// lets it replace an existing file; without it an existing path is ErrExists.
func (e *Editor) Upload(ctx context.Context, server, path string, src UploadSource, overwrite bool) error {
_, err := e.run(ctx, server, JobParams{
Op: OpUpload, Path: path, Overwrite: overwrite,
SourceURL: src.URL, UploadToken: src.Token, UploadSize: src.Size, UploadSHA256: src.SHA256,
})
return err
}
// run is the shared body of every operation: mint an op id, fill the rest of the
// params from the Config, run the Job, and translate the Result's code into a
// sentinel error. p carries the op and its own fields.
// //
// The size check happens HERE, before a Job is created, as well as inside the Pod. // The size check happens HERE, before a Job is created, as well as inside the Pod.
// That is not redundancy for its own sake: an oversized write would otherwise be // That is not redundancy for its own sake: an oversized write would otherwise be
// rejected by the API SERVER (etcd's object limit) as an opaque failure, long after // rejected by the API SERVER (etcd's object limit) as an opaque failure, long after
// felis-api had committed to the request, instead of as a clean 413. // felis-api had committed to the request, instead of as a clean 413.
func (e *Editor) run(ctx context.Context, server, op, path string, content []byte, expect string) (Result, error) { func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, error) {
if op == OpWrite && len(content) > MaxWriteBytes { if p.Op == OpWrite && len(p.Content) > MaxWriteBytes {
return Result{}, fmt.Errorf("%w: content is %d bytes, the limit is %d", return Result{}, fmt.Errorf("%w: content is %d bytes, the limit is %d",
ErrTooLarge, len(content), MaxWriteBytes) ErrTooLarge, len(p.Content), MaxWriteBytes)
} }
cfg := e.Config.withDefaults() cfg := e.Config.withDefaults()
@@ -252,26 +301,21 @@ func (e *Editor) run(ctx context.Context, server, op, path string, content []byt
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout) ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel() defer cancel()
payload, err := e.Runner.Run(ctx, JobParams{ p.Server = server
Server: server, p.OpID = opID
OpID: opID, p.WorldPVC = naming.WorldPVCName(server)
Op: op, p.Namespace = cfg.Namespace
Path: path, p.ServiceAccount = cfg.ServiceAccount
Content: content, p.Image = cfg.Image
Expect: expect, p.WorldsRoot = cfg.WorldsRoot
WorldPVC: naming.WorldPVCName(server), p.Deadline = cfg.Deadline
Namespace: cfg.Namespace, p.CPULimit = cfg.CPULimit
ServiceAccount: cfg.ServiceAccount, p.MemLimit = cfg.MemLimit
Image: cfg.Image, p.RunAsUser = cfg.RunAsUser
WorldsRoot: cfg.WorldsRoot, p.RunAsGroup = cfg.RunAsGroup
Deadline: cfg.Deadline, p.FSGroup = cfg.FSGroup
CPULimit: cfg.CPULimit, p.TTLAfterFinished = cfg.TTLAfterFinished
MemLimit: cfg.MemLimit, payload, err := e.Runner.Run(ctx, p)
RunAsUser: cfg.RunAsUser,
RunAsGroup: cfg.RunAsGroup,
FSGroup: cfg.FSGroup,
TTLAfterFinished: cfg.TTLAfterFinished,
})
if err != nil { if err != nil {
return Result{}, err return Result{}, err
} }
@@ -301,6 +345,8 @@ func resultError(res Result) error {
return fmt.Errorf("%w: %s", ErrConflict, res.Error) return fmt.Errorf("%w: %s", ErrConflict, res.Error)
case CodeNoSpace: case CodeNoSpace:
return fmt.Errorf("%w: %s", ErrNoSpace, res.Error) return fmt.Errorf("%w: %s", ErrNoSpace, res.Error)
case CodeExists:
return fmt.Errorf("%w: %s", ErrExists, res.Error)
default: default:
return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error) return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error)
} }
+53 -3
View File
@@ -82,14 +82,63 @@ func TestEditorRendersParams(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})} r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}} e := &Editor{Runner: r, Config: Config{Image: "img"}}
sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old") sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old", false)
if err != nil { if err != nil {
t.Fatalf("Write: %v", err) t.Fatalf("Write: %v", err)
} }
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" || sum != "new" { if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" ||
r.got[0].CreateOnly || sum != "new" {
t.Fatalf("params = %+v, sha256 = %q", r.got[0], sum) t.Fatalf("params = %+v, sha256 = %q", r.got[0], sum)
} }
}) })
t.Run("create-only write", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if _, err := e.Write(context.Background(), "survival", "new.yml", nil, "", true); err != nil {
t.Fatalf("Write: %v", err)
}
if !r.got[0].CreateOnly {
t.Fatalf("params = %+v, want CreateOnly", r.got[0])
}
})
t.Run("mkdir, delete and rename", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
ctx := context.Background()
if err := e.Mkdir(ctx, "survival", "plugins"); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := e.Delete(ctx, "survival", "old.jar"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := e.Rename(ctx, "survival", "a.txt", "b.txt"); err != nil {
t.Fatalf("Rename: %v", err)
}
for i, want := range []struct{ op, path, to string }{
{OpMkdir, "plugins", ""}, {OpDelete, "old.jar", ""}, {OpRename, "a.txt", "b.txt"},
} {
p := r.got[i]
if p.Op != want.op || p.Path != want.path || p.To != want.to || p.Server != "survival" || p.WorldPVC != "world-survival-0" {
t.Errorf("call %d params = %+v, want %+v", i, p, want)
}
}
})
t.Run("upload", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
src := UploadSource{URL: "http://api/x", Token: "tok", Size: 42, SHA256: "sum"}
if err := e.Upload(context.Background(), "survival", "plugins/x.jar", src, true); err != nil {
t.Fatalf("Upload: %v", err)
}
p := r.got[0]
if p.Op != OpUpload || p.Path != "plugins/x.jar" || p.SourceURL != "http://api/x" || p.UploadToken != "tok" ||
p.UploadSize != 42 || p.UploadSHA256 != "sum" || !p.Overwrite {
t.Fatalf("params = %+v", p)
}
})
} }
// TestEditorMintsAFreshOpID guards the RBAC-forced invariant from the other side: // TestEditorMintsAFreshOpID guards the RBAC-forced invariant from the other side:
@@ -132,6 +181,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
{"oversized", CodeTooLarge, ErrTooLarge}, {"oversized", CodeTooLarge, ErrTooLarge},
{"changed since read", CodeConflict, ErrConflict}, {"changed since read", CodeConflict, ErrConflict},
{"volume full", CodeNoSpace, ErrNoSpace}, {"volume full", CodeNoSpace, ErrNoSpace},
{"already there", CodeExists, ErrExists},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { t.Run(tc.name, func(t *testing.T) {
@@ -176,7 +226,7 @@ func TestEditorRefusesOversizedWriteBeforeTheCluster(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})} r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}} e := &Editor{Runner: r, Config: Config{Image: "img"}}
_, err := e.Write(context.Background(), "survival", "big.txt", make([]byte, MaxWriteBytes+1), "") _, err := e.Write(context.Background(), "survival", "big.txt", make([]byte, MaxWriteBytes+1), "", false)
if !errors.Is(err, ErrTooLarge) { if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err) t.Fatalf("err = %v, want ErrTooLarge", err)
} }
+372 -82
View File
@@ -19,29 +19,47 @@ import (
"felis.lolicon.best/internal/naming" "felis.lolicon.best/internal/naming"
) )
// The three operations the editor supports. The set is deliberately closed and // The operations the editor supports: list a directory, read a file, write a
// tiny: list a directory, read a file, write a file. There is no rename, delete, // file, make a directory, delete, rename, and upload. The set is closed; there is
// or chmod — each would need its own containment and audit story, and none is // no chmod, chown, link or copy. Every op resolves every path through os.Root (see
// required to fix a broken server.properties, which is what this subsystem exists // Execute), and each mutating op carries its own containment note below.
// for.
// //
// A write DOES accept arbitrary bytes at any path inside the mount, and that is a // A write or upload DOES land arbitrary bytes at any path inside the mount, and
// real capability rather than an oversight: the root is the server's whole working // that is a real capability rather than an oversight: the root is the server's
// directory (see Config.WorldsRoot), so an owner can write plugins/<x>.jar and // whole working directory (see Config.WorldsRoot), so an owner can upload
// Paper will load it on the next boot. It is the same power a hosting panel's file // plugins/<x>.jar and Paper will load it on the next boot. It is the same power a
// manager gives, scoped to a server the caller already owns and already controls // hosting panel's file manager gives, scoped to a server the caller already owns
// through /command. Note what it is NOT scoped by: admin image curation. Images // and already controls through /command. Note what it is NOT scoped by: admin
// are admin-only (POST /images, POST /images/build) and modpack submissions need // image curation. Images are admin-only (POST /images, POST /images/build) and
// an admin verdict, so this is the one owner-tier route that lands executable code // modpack submissions need an admin verdict, so this is the one owner-tier route
// in a backend pod. That trade was made deliberately; if it is ever revisited, the // that lands executable code in a backend pod. That trade was made deliberately;
// guard belongs in write() below, which is the single choke point all three // if it is ever revisited, the guard belongs in land() below, which is the single
// callers route through. // choke point both byte-landing ops route through.
const ( const (
OpList = "list" OpList = "list"
OpRead = "read" OpRead = "read"
OpWrite = "write" OpWrite = "write"
OpMkdir = "mkdir"
OpDelete = "delete"
OpRename = "rename"
OpUpload = "upload"
) )
// mutates reports whether op changes the world, and so whether its Job gets the
// world mount read-write. Only list and read leave the world alone; anything else
// counts as a change. maintenance.JobKind draws the same line for the world-volume
// lock.
func mutates(op string) bool { return op != OpList && op != OpRead }
// validOp reports whether op is one the Job knows.
func validOp(op string) bool {
switch op {
case OpList, OpRead, OpWrite, OpMkdir, OpDelete, OpRename, OpUpload:
return true
}
return false
}
// Result codes. A failure that is the CALLER's fault travels back as a Result // Result codes. A failure that is the CALLER's fault travels back as a Result
// with a Code rather than as a non-zero exit, so felis-api can map it onto a // with a Code rather than as a non-zero exit, so felis-api can map it onto a
// precise 4xx (handlers_files.go) instead of collapsing every failure into "the // precise 4xx (handlers_files.go) instead of collapsing every failure into "the
@@ -59,6 +77,10 @@ const (
// (the write is atomic), so this is the caller's volume being full rather // (the write is atomic), so this is the caller's volume being full rather
// than a bad request. // than a bad request.
CodeNoSpace = "no_space" CodeNoSpace = "no_space"
// CodeExists is a create, mkdir, rename or upload whose target is already
// there. None of them replaces anything unless told to (an upload's
// Overwrite), so a name collision is reported rather than resolved.
CodeExists = "exists"
) )
// ResultPrefix marks the single stdout line carrying the JSON Result. The Job's // ResultPrefix marks the single stdout line carrying the JSON Result. The Job's
@@ -70,15 +92,32 @@ const (
// JSON. Without it any stray stderr byte would corrupt every response. // JSON. Without it any stray stderr byte would corrupt every response.
const ResultPrefix = "FELIS-FILES-RESULT: " const ResultPrefix = "FELIS-FILES-RESULT: "
// ContentEnv is the environment variable the write path carries new file content // ContentEnv names the environment variables the write path carries new file
// in (base64). It travels on the Job spec felis-api creates, because felis-api // content in (base64). It travels on the Job spec felis-api creates, because
// holds `jobs: create` but NOT `secrets: create` in the minecraft namespace // felis-api holds `jobs: create` but NOT `secrets: create` in the minecraft
// (internal/platform.APIMinecraftRole) — a Secret is not available to it, so the // namespace (internal/platform.APIMinecraftRole) — a Secret is not available to
// Job spec is the only channel into the Pod. The consequence is that written // it, so the Job spec is the only channel into the Pod. The consequence is that
// content is readable by anyone holding jobs:get in the minecraft namespace, // written content is readable by anyone holding jobs:get in the minecraft
// which is a cluster-admin-level power; it is NOT readable by felis-operator, // namespace, which is a cluster-admin-level power; it is NOT readable by
// felis-reaper, or any weak Job SA, none of which hold that verb. // felis-operator, felis-reaper, or any weak Job SA, none of which hold that verb.
const ContentEnv = "FELIS_FILE_CONTENT" //
// The base64 is split across ContentEnv_0 … ContentEnv_<n-1>, with n in
// ContentPartsEnv. One variable cannot carry it: execve refuses any single
// environment string longer than MAX_ARG_STRLEN (32 pages, 128 KiB with 4 KiB
// pages), so a container whose one variable held the base64 of a 100 KiB file
// never started — the Pod failed with exit 255 before felis ran, and the save
// came back as an opaque 500. contentChunk keeps every part well under that.
const (
ContentEnv = "FELIS_FILE_CONTENT"
ContentPartsEnv = ContentEnv + "_PARTS"
contentChunk = 64 << 10
)
// UploadTokenEnv carries the one-time token an upload Job presents to felis-api
// to fetch the bytes it lands (see Stage). Like the content it rides the Job
// spec, and it opens exactly one thing — the one upload that Job was created
// for, once.
const UploadTokenEnv = "FELIS_UPLOAD_TOKEN"
// Size and count ceilings. Every one of them exists because the result travels // Size and count ceilings. Every one of them exists because the result travels
// through a Kubernetes object or a pod log, neither of which is an unbounded pipe: // through a Kubernetes object or a pod log, neither of which is an unbounded pipe:
@@ -94,10 +133,17 @@ const ContentEnv = "FELIS_FILE_CONTENT"
// - MaxEntries bounds a listing. A world's region/ directory legitimately holds // - MaxEntries bounds a listing. A world's region/ directory legitimately holds
// thousands of .mca files, so this truncates rather than errors (Truncated // thousands of .mca files, so this truncates rather than errors (Truncated
// says so), keeping the log line bounded while still being useful. // says so), keeping the log line bounded while still being useful.
// - MaxUploadBytes bounds an upload. Its bytes travel neither through the Job
// spec nor the pod log — felis-api stages them and the Job fetches them — so
// the bound is the request body instead: the Cloudflare edge refuses bodies
// over 100 MB on the Free and Pro plans, and 64 MiB covers the largest plugin
// jars (a Geyser build is about 20 MiB) with room to spare. A whole world is
// a different operation (a restore), not an upload.
const ( const (
MaxWriteBytes = 256 << 10 // 256 KiB MaxWriteBytes = 256 << 10 // 256 KiB
MaxReadBytes = 1 << 20 // 1 MiB MaxReadBytes = 1 << 20 // 1 MiB
MaxEntries = 2000 MaxEntries = 2000
MaxUploadBytes = 64 << 20 // 64 MiB
) )
// Entry is one directory entry in a listing. It carries only what a file browser // Entry is one directory entry in a listing. It carries only what a file browser
@@ -113,7 +159,7 @@ type Entry struct {
} }
// Result is the single JSON object the Job prints and felis-api parses back. One // Result is the single JSON object the Job prints and felis-api parses back. One
// shape covers all three ops so the transport has exactly one thing to find and // shape covers every op so the transport has exactly one thing to find and
// unmarshal; the op decides which fields are populated. // unmarshal; the op decides which fields are populated.
// //
// Content is []byte, so encoding/json base64-encodes it on the way out and // Content is []byte, so encoding/json base64-encodes it on the way out and
@@ -134,14 +180,46 @@ type Result struct {
Truncated bool `json:"truncated,omitempty"` Truncated bool `json:"truncated,omitempty"`
// SHA256 is the hex digest of the file's on-disk bytes: after a read, the file // SHA256 is the hex digest of the file's on-disk bytes: after a read, the file
// as read (before any redaction); after a write, the bytes written; on a // as read (before any redaction); after a write, the bytes written; on a
// conflict, the file as it is now. A client hands it back as the expected hash // conflict, the file as it is now. A client hands it back as the expected
// of its next write (see write). // hash of its next write (see write).
SHA256 string `json:"sha256,omitempty"` SHA256 string `json:"sha256,omitempty"`
} }
// Execute performs op on the file named by path, resolved inside root, and returns // Request is one file operation. Op decides which of the other fields it reads.
// the Result to print. root is the in-Pod mount path of the server's world PVC; type Request struct {
// path is the caller-supplied relative path underneath it. Op string
Path string
// To is a rename's destination.
To string
// Content and Expect are a write's bytes and precondition: when Expect is
// non-empty, the write lands only if the file's current SHA-256 (hex) equals
// it.
Content []byte
Expect string
// CreateOnly makes a write refuse a path that already exists. It is the
// panel's "new file", which must never truncate a file it did not know was
// there.
CreateOnly bool
// Upload is where an upload's bytes come from; Overwrite lets it replace a
// file already at the path.
Upload *Upload
Overwrite bool
}
// Upload describes the bytes an upload lands. Size and SHA256 are what felis-api
// received from the caller; the fetched bytes must match both before they replace
// anything.
type Upload struct {
Size int64
SHA256 string
// Open starts the transfer. It runs only once the target has passed every
// check, so a refused upload never pulls the bytes.
Open func() (io.ReadCloser, error)
}
// Execute performs one operation inside root and returns the Result to print.
// root is the in-Pod mount path of the server's world PVC; every path in req is
// relative to it.
// //
// CONTAINMENT INVARIANT: every filesystem access goes through *os.Root, never // CONTAINMENT INVARIANT: every filesystem access goes through *os.Root, never
// through a path string this function assembled. os.Root is the stdlib's // through a path string this function assembled. os.Root is the stdlib's
@@ -162,11 +240,14 @@ type Result struct {
// to os.Root as-is and refused. Silently reinterpreting "/etc/passwd" as // to os.Root as-is and refused. Silently reinterpreting "/etc/passwd" as
// "<root>/etc/passwd" would turn an unambiguous escape attempt into a successful // "<root>/etc/passwd" would turn an unambiguous escape attempt into a successful
// read of a file the caller did not name, which is exactly the confusion this // read of a file the caller did not name, which is exactly the confusion this
// editor must not have. // editor must not have. mkdir, delete and rename do path.Clean the path first (so
// a trailing slash cannot make them act on a link's target), and Clean keeps both
// a leading "/" and a leading "..", so an escape stays an escape.
// //
// expect is a write's precondition: when non-empty, the write lands only if the // The error is an infrastructure failure: the world mount unopenable, an unknown
// file's current SHA-256 (hex) equals it. It is ignored by list and read. // op, or an upload whose transfer broke. A caller's mistake is a Result with a
func Execute(root, op, path string, content []byte, expect string) (Result, error) { // Code.
func Execute(root string, req Request) (Result, error) {
r, err := os.OpenRoot(root) r, err := os.OpenRoot(root)
if err != nil { if err != nil {
// The world mount itself is unopenable: infrastructure, not caller fault. // The world mount itself is unopenable: infrastructure, not caller fault.
@@ -174,19 +255,31 @@ func Execute(root, op, path string, content []byte, expect string) (Result, erro
} }
defer r.Close() defer r.Close()
path := req.Path
if path == "" { if path == "" {
path = "." path = "."
} }
switch op { switch req.Op {
case OpList: case OpList:
return list(r, path), nil return list(r, path), nil
case OpRead: case OpRead:
return read(r, path), nil return read(r, path), nil
case OpWrite: case OpWrite:
return write(r, path, content, expect), nil return write(r, path, req.Content, req.Expect, req.CreateOnly), nil
case OpMkdir:
return mkdir(r, path), nil
case OpDelete:
return remove(r, path), nil
case OpRename:
return rename(r, path, req.To), nil
case OpUpload:
if req.Upload == nil {
return Result{}, errors.New("an upload needs a source")
}
return upload(r, path, *req.Upload, req.Overwrite)
default: default:
return Result{}, fmt.Errorf("unknown op %q", op) return Result{}, fmt.Errorf("unknown op %q", req.Op)
} }
} }
@@ -339,17 +432,9 @@ func redactSecretProps(name string, content []byte) []byte {
// path this editor writes is an existing config file being corrected, so an // path this editor writes is an existing config file being corrected, so an
// unexpected mkdir would more likely be a typo materialising a stray directory in // unexpected mkdir would more likely be a typo materialising a stray directory in
// the world mount than an intent. A missing file is still created, so a config // the world mount than an intent. A missing file is still created, so a config
// the server has not yet generated can be authored. // the server has not yet generated can be authored; createOnly refuses a path that
// // already exists, which is how the panel's "new file" avoids truncating a file it
// The replacement is atomic. The bytes go to a temporary sibling that is synced // did not know was there.
// and then renamed over the target, so a full disk, a Job killed at its deadline
// or a crashed node leaves either the old file or the new one — never the
// zero-length or half-written server.properties an in-place truncate would, which
// is a server that no longer boots. The sibling keeps the target's mode and is
// handed to the game uid before the rename, so the file the server finds is never
// root's. On failure it is removed; only a kill between create and rename leaves
// one behind, named ".<file>.felis-edit-<hex>" so no loader mistakes it for a
// plugin jar or a config.
// //
// expect, when set, is the SHA-256 the caller read the file at (Result.SHA256 of // expect, when set, is the SHA-256 the caller read the file at (Result.SHA256 of
// its read). A file that has changed since — another manager saved it, or the // its read). A file that has changed since — another manager saved it, or the
@@ -357,12 +442,7 @@ func redactSecretProps(name string, content []byte) []byte {
// being overwritten, which is how two people editing the same file find out. // being overwritten, which is how two people editing the same file find out.
// The world lock (internal/maintenance) already serialises writes, so the check // The world lock (internal/maintenance) already serialises writes, so the check
// and the rename cannot interleave with another write. // and the rename cannot interleave with another write.
// func write(r *os.Root, name string, content []byte, expect string, createOnly bool) Result {
// os.Root applies the same containment to every step. A symlink at the target is
// followed only while it stays inside the root (resolveLink), so a planted link
// to a file outside is refused and the rename replaces the file the link names,
// never the link itself.
func write(r *os.Root, name string, content []byte, expect string) Result {
if len(content) > MaxWriteBytes { if len(content) > MaxWriteBytes {
// Defence in depth: felis-api already refuses an oversized write with a 413 // Defence in depth: felis-api already refuses an oversized write with a 413
// before rendering the Job. Re-checking here keeps the ceiling true even if // before rendering the Job. Re-checking here keeps the ceiling true even if
@@ -370,38 +450,94 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
return Result{Code: CodeTooLarge, Error: fmt.Sprintf( return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
"content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)} "content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)}
} }
target, res := resolveLink(r, name) target, mode, res := landingTarget(r, name, !createOnly)
if res.Code != "" { if res.Code != "" {
return res return res
} }
mode := fs.FileMode(0o644)
info, err := r.Lstat(target)
switch {
case err == nil && info.IsDir():
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
case err == nil && !info.Mode().IsRegular():
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a regular file", name)}
case err == nil:
mode = info.Mode().Perm()
case !errors.Is(err, fs.ErrNotExist):
return failure(err, name)
}
if expect != "" { if expect != "" {
if res := checkUnchanged(r, name, target, expect); res.Code != "" { if res := checkUnchanged(r, name, target, expect); res.Code != "" {
return res return res
} }
} }
// A write's fill never returns a transfer error, so land's error is always nil.
res, _ = land(r, name, target, mode, func(w io.Writer) error {
_, err := w.Write(content)
return err
})
if res.Code == "" {
res.SHA256 = digest(content)
}
return res
}
// landingTarget decides where a write or upload lands and with what mode. A
// symlink at name is followed only while it stays inside the root (resolveLink), so
// a planted link to a file outside is refused and the rename in land replaces the
// file the link names, never the link itself. The target keeps its mode; a new file
// gets 0644.
//
// mayExist false refuses a name that is already there in any form — file,
// directory or link, dangling or not — before any link is followed.
func landingTarget(r *os.Root, name string, mayExist bool) (string, fs.FileMode, Result) {
if !mayExist {
if _, err := r.Lstat(name); err == nil {
return "", 0, Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
} else if !errors.Is(err, fs.ErrNotExist) {
return "", 0, failure(err, name)
}
}
target, res := resolveLink(r, name)
if res.Code != "" {
return "", 0, res
}
info, err := r.Lstat(target)
switch {
case err == nil && info.IsDir():
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
case err == nil && !info.Mode().IsRegular():
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a regular file", name)}
case err == nil:
return target, info.Mode().Perm(), Result{}
case errors.Is(err, fs.ErrNotExist):
return target, 0o644, Result{}
default:
return "", 0, failure(err, name)
}
}
// transferError marks an upload whose bytes could not be fetched intact. It is
// infrastructure — felis-api staged the bytes and serves them to this Job — so it
// leaves Execute as an error (a non-zero exit, a 500) rather than a caller-facing
// code.
type transferError struct{ err error }
func (e *transferError) Error() string { return "fetch upload: " + e.err.Error() }
func (e *transferError) Unwrap() error { return e.err }
// land atomically puts the bytes fill writes at target, the path landingTarget
// returned for name. It is the single choke point both byte-landing ops (write and
// upload) route through.
//
// The bytes go to a temporary sibling that is synced and then renamed over the
// target, so a full disk, a Job killed at its deadline or a crashed node leaves
// either the old file or the new one — never the zero-length or half-written
// server.properties an in-place truncate would, which is a server that no longer
// boots. The sibling gets mode and is handed to the game uid before the rename, so
// the file the server finds is never root's. On failure it is removed; only a kill
// between create and rename leaves one behind, named ".<file>.felis-edit-<hex>" so
// no loader mistakes it for a plugin jar or a config.
//
// A *transferError from fill comes back as the error; every other failure is a
// Result.
func land(r *os.Root, name, target string, mode fs.FileMode, fill func(io.Writer) error) (Result, error) {
var suffix [6]byte var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil { if _, err := rand.Read(suffix[:]); err != nil {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)} return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}, nil
} }
tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:])) tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:]))
f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil { if err != nil {
return writeFailure(err, name) return writeFailure(err, name), nil
} }
renamed := false renamed := false
defer func() { defer func() {
@@ -413,21 +549,25 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
// owner had at 0664 or 0600 should come back the same. // owner had at 0664 or 0600 should come back the same.
if err := f.Chmod(mode); err != nil { if err := f.Chmod(mode); err != nil {
f.Close() f.Close()
return writeFailure(err, name) return writeFailure(err, name), nil
} }
if _, err := f.Write(content); err != nil { if err := fill(f); err != nil {
f.Close() f.Close()
return writeFailure(err, name) var te *transferError
if errors.As(err, &te) {
return Result{}, err
}
return writeFailure(err, name), nil
} }
// Sync before the rename, or a crash could leave the new name pointing at // Sync before the rename, or a crash could leave the new name pointing at
// blocks that never reached the disk. Close is where a buffered-write error // blocks that never reached the disk. Close is where a buffered-write error
// surfaces, so its error is honoured rather than deferred-and-dropped. // surfaces, so its error is honoured rather than deferred-and-dropped.
if err := syncWritten(f); err != nil { if err := syncWritten(f); err != nil {
f.Close() f.Close()
return writeFailure(err, name) return writeFailure(err, name), nil
} }
if err := f.Close(); err != nil { if err := f.Close(); err != nil {
return writeFailure(err, name) return writeFailure(err, name), nil
} }
// The Job runs as root, so the file it just created is root's. The server runs // The Job runs as root, so the file it just created is root's. The server runs
// as the game uid and could read it but never rewrite it — a config the panel // as the game uid and could read it but never rewrite it — a config the panel
@@ -435,16 +575,166 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
// prepare-data initContainer re-owns anything left behind on its next start. // prepare-data initContainer re-owns anything left behind on its next start.
_ = ownWritten(r, tmp) _ = ownWritten(r, tmp)
if err := r.Rename(tmp, target); err != nil { if err := r.Rename(tmp, target); err != nil {
return writeFailure(err, name) return writeFailure(err, name), nil
} }
renamed = true renamed = true
// The rename lives in the directory; sync it so the new entry survives a crash syncDir(r, path.Dir(target))
// too. Best effort: the content has landed and reporting failure would lie. return Result{}, nil
if d, err := r.Open(path.Dir(target)); err == nil { }
// syncDir syncs a directory so an entry just added, renamed or removed survives a
// crash too. Best effort: the change has happened and reporting failure would lie.
func syncDir(r *os.Root, dir string) {
if d, err := r.Open(dir); err == nil {
_ = d.Sync() _ = d.Sync()
d.Close() d.Close()
} }
return Result{SHA256: digest(content)} }
// upload lands a file fetched from felis-api (see Stage). Everything that can
// refuse it is checked before u.Open, so a refused upload never pulls its bytes.
// The fetched bytes must match both the size and the SHA-256 felis-api received;
// either mismatch is a broken transfer, and the target is left as it was. A
// success has landed exactly what felis-api staged, whose digest it already holds.
func upload(r *os.Root, name string, u Upload, overwrite bool) (Result, error) {
if u.Size > MaxUploadBytes {
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
"the upload is %d bytes; the editor uploads at most %d", u.Size, MaxUploadBytes)}, nil
}
target, mode, res := landingTarget(r, name, overwrite)
if res.Code != "" {
return res, nil
}
return land(r, name, target, mode, func(w io.Writer) error {
body, err := u.Open()
if err != nil {
return &transferError{err}
}
defer body.Close()
h := sha256.New()
// One byte past Size so a source that sends more than it promised is seen.
n, err := io.Copy(io.MultiWriter(w, h), sourceReader{io.LimitReader(body, u.Size+1)})
if err != nil {
return err
}
if n != u.Size {
return &transferError{fmt.Errorf("got %d bytes, expected %d", n, u.Size)}
}
if sum := hex.EncodeToString(h.Sum(nil)); sum != u.SHA256 {
return &transferError{fmt.Errorf("got sha256 %s, expected %s", sum, u.SHA256)}
}
return nil
})
}
// sourceReader tags the source's read errors as transfer errors, so land can tell
// a broken fetch from the volume filling up underneath the copy.
type sourceReader struct{ r io.Reader }
func (s sourceReader) Read(p []byte) (int, error) {
n, err := s.r.Read(p)
if err != nil && err != io.EOF {
err = &transferError{err}
}
return n, err
}
// mkdir makes one directory, handed to the game uid. It does not make parents:
// the panel creates a folder inside the one it is showing, so a missing parent is
// a stale view, reported as such.
//
// The path is cleaned first so a trailing slash cannot slip past the "." check.
// Clean keeps a leading "/" or "..", so os.Root still sees — and refuses — an
// escape.
func mkdir(r *os.Root, name string) Result {
name = path.Clean(name)
if name == "." {
return Result{Code: CodeBadPath, Error: "a folder needs a name"}
}
if err := r.Mkdir(name, 0o755); err != nil {
switch {
case errors.Is(err, fs.ErrExist):
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
case errors.Is(err, fs.ErrNotExist):
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(name))}
}
return writeFailure(err, name)
}
_ = ownWritten(r, name)
syncDir(r, path.Dir(name))
return Result{}
}
// remove deletes a file, a link or a whole directory. RemoveAll removes a symlink
// itself, never what it points at, and os.Root keeps it inside the mount; the Lstat
// is there because RemoveAll reports nothing for a path that is not there. The
// root itself is refused — emptying a server's whole volume is a reset, which has
// its own path.
func remove(r *os.Root, name string) Result {
name = path.Clean(name)
if name == "." {
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be deleted"}
}
if _, err := r.Lstat(name); err != nil {
return failure(err, name)
}
if err := r.RemoveAll(name); err != nil {
return failure(err, name)
}
syncDir(r, path.Dir(name))
return Result{}
}
// rename moves from to to, both inside the root. It never replaces: a destination
// that exists is CodeExists, so a mistyped name cannot silently destroy another
// file. A missing destination folder is not made.
func rename(r *os.Root, from, to string) Result {
from, to = path.Clean(from), path.Clean(to)
if from == "." || to == "." {
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be moved"}
}
info, err := r.Lstat(from)
if err != nil {
return failure(err, from)
}
if res := guardMove(r, from, info); res.Code != "" {
return res
}
if _, err := r.Lstat(to); err == nil {
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", to)}
} else if !errors.Is(err, fs.ErrNotExist) {
return failure(err, to)
}
if err := r.Rename(from, to); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(to))}
}
return failure(err, to)
}
syncDir(r, path.Dir(from))
syncDir(r, path.Dir(to))
return Result{}
}
// guardedPaths are the paths read guards by name: secretConfigPath is refused and
// propsPath has its RCON password redacted. Moving either — or the config folder
// holding the first — to another name would make the next read hand back what the
// guard withholds, so rename refuses them.
var guardedPaths = []string{secretConfigPath, path.Dir(secretConfigPath), propsPath}
// guardMove refuses a rename whose source is a guarded path under any name: the
// comparison is by file identity, so "./config", a link's target or a folder
// reached through a link are all caught.
func guardMove(r *os.Root, from string, info fs.FileInfo) Result {
for _, g := range guardedPaths {
for _, stat := range []func(string) (fs.FileInfo, error){r.Lstat, r.Stat} {
if gi, err := stat(g); err == nil && os.SameFile(info, gi) {
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
"%s is managed by felis and cannot be moved or renamed", from)}
}
}
}
return Result{}
} }
// writeFailure is failure for the steps that move bytes, where a full volume is // writeFailure is failure for the steps that move bytes, where a full volume is
+33 -28
View File
@@ -36,6 +36,11 @@ func worldRoot(t *testing.T) (root, outside string) {
return root, outside return root, outside
} }
// run executes one list, read or write the way the Job does.
func run(root, op, path string, content []byte, expect string) (Result, error) {
return Execute(root, Request{Op: op, Path: path, Content: content, Expect: expect})
}
// TestExecuteContainment is the security test of this package. The world directory // TestExecuteContainment is the security test of this package. The world directory
// holds attacker-influenced content (players and plugins create files in it), so // holds attacker-influenced content (players and plugins create files in it), so
// each vector below is a path a caller could genuinely supply to try to leave the // each vector below is a path a caller could genuinely supply to try to leave the
@@ -76,7 +81,7 @@ func TestExecuteContainment(t *testing.T) {
for _, v := range vectors { for _, v := range vectors {
t.Run("read "+v.name, func(t *testing.T) { t.Run("read "+v.name, func(t *testing.T) {
res, err := Execute(root, OpRead, v.path, nil, "") res, err := run(root, OpRead, v.path, nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute returned an infrastructure error, want a contained refusal: %v", err) t.Fatalf("Execute returned an infrastructure error, want a contained refusal: %v", err)
} }
@@ -92,7 +97,7 @@ func TestExecuteContainment(t *testing.T) {
// The write side must be contained by the same invariant: a planted symlink // The write side must be contained by the same invariant: a planted symlink
// must not become a write into the file it points at. // must not become a write into the file it points at.
t.Run("write through a planted symlink is refused", func(t *testing.T) { t.Run("write through a planted symlink is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "planted.txt", []byte("pwned"), "") res, err := run(root, OpWrite, "planted.txt", []byte("pwned"), "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -109,7 +114,7 @@ func TestExecuteContainment(t *testing.T) {
}) })
t.Run("write escaping by traversal is refused", func(t *testing.T) { t.Run("write escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"), "") res, err := run(root, OpWrite, "../outside/new.txt", []byte("pwned"), "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -122,7 +127,7 @@ func TestExecuteContainment(t *testing.T) {
}) })
t.Run("list escaping by traversal is refused", func(t *testing.T) { t.Run("list escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpList, "../outside", nil, "") res, err := run(root, OpList, "../outside", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -139,7 +144,7 @@ func TestExecuteHappyPath(t *testing.T) {
root, _ := worldRoot(t) root, _ := worldRoot(t)
t.Run("list the world root", func(t *testing.T) { t.Run("list the world root", func(t *testing.T) {
res, err := Execute(root, OpList, "", nil, "") res, err := run(root, OpList, "", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -162,7 +167,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("list a subdirectory", func(t *testing.T) { t.Run("list a subdirectory", func(t *testing.T) {
res, err := Execute(root, OpList, "config", nil, "") res, err := run(root, OpList, "config", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -172,7 +177,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("read a file", func(t *testing.T) { t.Run("read a file", func(t *testing.T) {
res, err := Execute(root, OpRead, "server.properties", nil, "") res, err := run(root, OpRead, "server.properties", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -182,7 +187,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("write replaces content, then reads back", func(t *testing.T) { t.Run("write replaces content, then reads back", func(t *testing.T) {
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=changed\n"), ""); err != nil || res.Code != "" { if res, err := run(root, OpWrite, "server.properties", []byte("motd=changed\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write failed: %v / %+v", err, res) t.Fatalf("write failed: %v / %+v", err, res)
} }
b, err := os.ReadFile(filepath.Join(root, "server.properties")) b, err := os.ReadFile(filepath.Join(root, "server.properties"))
@@ -202,13 +207,13 @@ func TestExecuteHappyPath(t *testing.T) {
return os.ErrPermission // a test runner cannot chown; the write must still succeed return os.ErrPermission // a test runner cannot chown; the write must still succeed
} }
defer func() { ownWritten = prev }() defer func() { ownWritten = prev }()
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" { if res, err := run(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" {
t.Fatalf("creating a new file should succeed: %v / %+v", err, res) t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
} }
if len(owned) != 1 || !strings.HasPrefix(owned[0], ".ops.json.felis-edit-") { if len(owned) != 1 || !strings.HasPrefix(owned[0], ".ops.json.felis-edit-") {
t.Errorf("files handed to the game uid = %v, want the one temporary sibling of ops.json", owned) t.Errorf("files handed to the game uid = %v, want the one temporary sibling of ops.json", owned)
} }
res, err := Execute(root, OpWrite, "nope/deep.txt", []byte("x"), "") res, err := run(root, OpWrite, "nope/deep.txt", []byte("x"), "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -218,7 +223,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("missing file reads as not_found", func(t *testing.T) { t.Run("missing file reads as not_found", func(t *testing.T) {
res, err := Execute(root, OpRead, "absent.txt", nil, "") res, err := run(root, OpRead, "absent.txt", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -228,7 +233,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("reading a directory is bad_path, not a garbled read", func(t *testing.T) { t.Run("reading a directory is bad_path, not a garbled read", func(t *testing.T) {
res, err := Execute(root, OpRead, "config", nil, "") res, err := run(root, OpRead, "config", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -238,7 +243,7 @@ func TestExecuteHappyPath(t *testing.T) {
}) })
t.Run("oversized write is refused", func(t *testing.T) { t.Run("oversized write is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "") res, err := run(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -251,7 +256,7 @@ func TestExecuteHappyPath(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, "huge.bin"), make([]byte, MaxReadBytes+1), 0o644); err != nil { if err := os.WriteFile(filepath.Join(root, "huge.bin"), make([]byte, MaxReadBytes+1), 0o644); err != nil {
t.Fatalf("write huge: %v", err) t.Fatalf("write huge: %v", err)
} }
res, err := Execute(root, OpRead, "huge.bin", nil, "") res, err := run(root, OpRead, "huge.bin", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -272,7 +277,7 @@ func TestReadIsBinarySafe(t *testing.T) {
t.Fatalf("write raw: %v", err) t.Fatalf("write raw: %v", err)
} }
res, err := Execute(root, OpRead, "raw.bin", nil, "") res, err := run(root, OpRead, "raw.bin", nil, "")
if err != nil || res.Code != "" { if err != nil || res.Code != "" {
t.Fatalf("read failed: %v / %+v", err, res) t.Fatalf("read failed: %v / %+v", err, res)
} }
@@ -333,7 +338,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
"config/../config/paper-global.yml", "config/../config/paper-global.yml",
"config/./paper-global.yml", "config/./paper-global.yml",
} { } {
res, err := Execute(root, OpRead, spelling, nil, "") res, err := run(root, OpRead, spelling, nil, "")
if err != nil { if err != nil {
t.Fatalf("%s: Execute: %v", spelling, err) t.Fatalf("%s: Execute: %v", spelling, err)
} }
@@ -348,7 +353,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// The denial is READ-only and exact: a neighbouring file in the same directory // The denial is READ-only and exact: a neighbouring file in the same directory
// stays readable, or the guard would have broken ordinary config repair. // stays readable, or the guard would have broken ordinary config repair.
res, err := Execute(root, OpRead, "config/paper.yml", nil, "") res, err := run(root, OpRead, "config/paper.yml", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -358,7 +363,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// Writing it is still allowed: it leaks nothing, and the lobby entrypoint // Writing it is still allowed: it leaks nothing, and the lobby entrypoint
// rewrites the file whole on every boot regardless. // rewrites the file whole on every boot regardless.
res, err = Execute(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"), "") res, err = run(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"), "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -387,7 +392,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
t.Fatalf("write nested server.properties: %v", err) t.Fatalf("write nested server.properties: %v", err)
} }
res, err := Execute(root, OpRead, "server.properties", nil, "") res, err := run(root, OpRead, "server.properties", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute: %v", err) t.Fatalf("Execute: %v", err)
} }
@@ -406,7 +411,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
} }
} }
nested, err := Execute(root, OpRead, "plugins/server.properties", nil, "") nested, err := run(root, OpRead, "plugins/server.properties", nil, "")
if err != nil { if err != nil {
t.Fatalf("Execute nested: %v", err) t.Fatalf("Execute nested: %v", err)
} }
@@ -426,7 +431,7 @@ func TestWriteIsAtomic(t *testing.T) {
prev := syncWritten prev := syncWritten
syncWritten = func(*os.File) error { return syscall.ENOSPC } syncWritten = func(*os.File) error { return syscall.ENOSPC }
defer func() { syncWritten = prev }() defer func() { syncWritten = prev }()
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=half"), "") res, err := run(root, OpWrite, "server.properties", []byte("motd=half"), "")
if err != nil || res.Code != CodeNoSpace { if err != nil || res.Code != CodeNoSpace {
t.Fatalf("Execute = %+v, %v; want no_space", res, err) t.Fatalf("Execute = %+v, %v; want no_space", res, err)
} }
@@ -440,7 +445,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Chmod(props, 0o600); err != nil { if err := os.Chmod(props, 0o600); err != nil {
t.Fatal(err) t.Fatal(err)
} }
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" { if res, err := run(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res) t.Fatalf("write: %v / %+v", err, res)
} }
info, err := os.Stat(props) info, err := os.Stat(props)
@@ -457,7 +462,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil { if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
if res, err := Execute(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" { if res, err := run(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res) t.Fatalf("write: %v / %+v", err, res)
} }
if b, _ := os.ReadFile(filepath.Join(root, "config", "paper.yml")); string(b) != "verbose: true\n" { if b, _ := os.ReadFile(filepath.Join(root, "config", "paper.yml")); string(b) != "verbose: true\n" {
@@ -472,7 +477,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Symlink("../../outside/secret.txt", filepath.Join(root, "config", "climb")); err != nil { if err := os.Symlink("../../outside/secret.txt", filepath.Join(root, "config", "climb")); err != nil {
t.Skipf("symlinks unavailable: %v", err) t.Skipf("symlinks unavailable: %v", err)
} }
res, err := Execute(root, OpWrite, "config/climb", []byte("pwned"), "") res, err := run(root, OpWrite, "config/climb", []byte("pwned"), "")
if err != nil || res.Code != CodeBadPath { if err != nil || res.Code != CodeBadPath {
t.Fatalf("Execute = %+v, %v; want bad_path", res, err) t.Fatalf("Execute = %+v, %v; want bad_path", res, err)
} }
@@ -488,7 +493,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
read, err := Execute(root, OpRead, "server.properties", nil, "") read, err := run(root, OpRead, "server.properties", nil, "")
if err != nil || read.Code != "" || len(read.SHA256) != 64 { if err != nil || read.Code != "" || len(read.SHA256) != 64 {
t.Fatalf("read = %+v, %v; want content and a sha256", read, err) t.Fatalf("read = %+v, %v; want content and a sha256", read, err)
} }
@@ -502,7 +507,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
if err := os.WriteFile(props, []byte("motd=theirs\n"), 0o644); err != nil { if err := os.WriteFile(props, []byte("motd=theirs\n"), 0o644); err != nil {
t.Fatal(err) t.Fatal(err)
} }
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256) res, err := run(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256)
if err != nil || res.Code != CodeConflict { if err != nil || res.Code != CodeConflict {
t.Fatalf("stale write = %+v, %v; want a conflict", res, err) t.Fatalf("stale write = %+v, %v; want a conflict", res, err)
} }
@@ -514,7 +519,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
} }
// With the current hash the save lands and reports the new one. // With the current hash the save lands and reports the new one.
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256) res, err = run(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != "" || res.SHA256 != digest([]byte("motd=mine\n")) { if err != nil || res.Code != "" || res.SHA256 != digest([]byte("motd=mine\n")) {
t.Fatalf("fresh write = %+v, %v", res, err) t.Fatalf("fresh write = %+v, %v", res, err)
} }
@@ -523,7 +528,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
if err := os.Remove(props); err != nil { if err := os.Remove(props); err != nil {
t.Fatal(err) t.Fatal(err)
} }
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256) res, err = run(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != CodeConflict { if err != nil || res.Code != CodeConflict {
t.Fatalf("write over a deleted file = %+v, %v; want a conflict", res, err) t.Fatalf("write over a deleted file = %+v, %v; want a conflict", res, err)
} }
+62 -25
View File
@@ -1,8 +1,8 @@
package fileedit package fileedit
import ( import (
"encoding/base64"
"fmt" "fmt"
"strconv"
"time" "time"
batchv1 "k8s.io/api/batch/v1" batchv1 "k8s.io/api/batch/v1"
@@ -46,8 +46,19 @@ type JobParams struct {
Path string Path string
Content []byte // OpWrite only Content []byte // OpWrite only
// Expect is a write's precondition hash (see Execute); empty writes // Expect is a write's precondition hash (see Execute); empty writes
// unconditionally. // unconditionally. CreateOnly makes a write refuse an existing path.
Expect string Expect string
CreateOnly bool
// To is a rename's destination.
To string
// SourceURL, UploadToken, UploadSize and UploadSHA256 tell an upload Job where
// to fetch its bytes and what they must be (see Stage); Overwrite lets it
// replace an existing file.
SourceURL string
UploadToken string
UploadSize int64
UploadSHA256 string
Overwrite bool
WorldPVC string WorldPVC string
Namespace string Namespace string
@@ -104,8 +115,8 @@ func filesLabels(p JobParams) map[string]string {
// mounts the config Secret to self-record its row: a file-editor Pod has nothing // mounts the config Secret to self-record its row: a file-editor Pod has nothing
// to record, so it is handed no database URL and no credential of any kind (the // to record, so it is handed no database URL and no credential of any kind (the
// four-power red line, spec §22); // four-power red line, spec §22);
// - mounts that one volume READ-ONLY for list and read. Only a write needs to // - mounts that one volume READ-ONLY for list and read (see mutates), so the
// mutate the world, so two of the three operations physically cannot — the // two operations that only look physically cannot change anything — the
// kernel refuses, not merely the code. This is why readOnly is derived from the // kernel refuses, not merely the code. This is why readOnly is derived from the
// op rather than fixed; // op rather than fixed;
// - runs as a non-root, fixed uid/gid with an fsGroup matching the operator's // - runs as a non-root, fixed uid/gid with an fsGroup matching the operator's
@@ -113,7 +124,8 @@ func filesLabels(p JobParams) map[string]string {
// server later runs as — a config file the server cannot read would be worse // server later runs as — a config file the server cannot read would be worse
// than no edit at all; // than no edit at all;
// - no privilege, no privilege escalation, read-only root filesystem, drop ALL // - no privilege, no privilege escalation, read-only root filesystem, drop ALL
// capabilities. The world mount is the only writable path, and only on a write; // capabilities. The world mount is the only writable path, and only for an op
// that mutates;
// - activeDeadlineSeconds + backoffLimit=0 so a wedged mount cannot loop or hang // - activeDeadlineSeconds + backoffLimit=0 so a wedged mount cannot loop or hang
// forever; ttlSecondsAfterFinished GCs the finished Job, which — see // forever; ttlSecondsAfterFinished GCs the finished Job, which — see
// FilesJobName — is the ONLY cleanup available to felis-api. // FilesJobName — is the ONLY cleanup available to felis-api.
@@ -131,12 +143,15 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
if p.OpID == "" { if p.OpID == "" {
return nil, fmt.Errorf("fileedit: op id is required") return nil, fmt.Errorf("fileedit: op id is required")
} }
if p.Op != OpList && p.Op != OpRead && p.Op != OpWrite { if !validOp(p.Op) {
return nil, fmt.Errorf("fileedit: unknown op %q", p.Op) return nil, fmt.Errorf("fileedit: unknown op %q", p.Op)
} }
if len(p.Content) > MaxWriteBytes { if len(p.Content) > MaxWriteBytes {
return nil, fmt.Errorf("fileedit: content is %d bytes, over the %d limit", len(p.Content), MaxWriteBytes) return nil, fmt.Errorf("fileedit: content is %d bytes, over the %d limit", len(p.Content), MaxWriteBytes)
} }
if p.Op == OpUpload && (p.SourceURL == "" || p.UploadToken == "") {
return nil, fmt.Errorf("fileedit: an upload needs a source URL and a token")
}
limits, err := resourceLimits(p.CPULimit, p.MemLimit) limits, err := resourceLimits(p.CPULimit, p.MemLimit)
if err != nil { if err != nil {
return nil, err return nil, err
@@ -150,10 +165,10 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
ttl = int32(defaultTTL / time.Second) ttl = int32(defaultTTL / time.Second)
} }
// Only a write may mutate the world. Mounting read-only for the other two ops // Only an op that changes the world gets it read-write. Mounting read-only for
// makes "a listing cannot damage a world" a kernel guarantee rather than a // list and read makes "a listing cannot damage a world" a kernel guarantee
// code-review one. // rather than a code-review one.
readOnlyWorld := p.Op != OpWrite readOnlyWorld := !mutates(p.Op)
args := []string{ args := []string{
"--op", p.Op, "--op", p.Op,
@@ -162,9 +177,25 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
} }
// The expected hash is a digest of content the caller already holds, not a // The expected hash is a digest of content the caller already holds, not a
// secret, so it rides argv; only the content itself needs the env channel. // secret, so it rides argv; only the content itself needs the env channel.
if p.Op == OpWrite && p.Expect != "" { switch p.Op {
case OpWrite:
if p.Expect != "" {
args = append(args, "--expect-sha256", p.Expect) args = append(args, "--expect-sha256", p.Expect)
} }
if p.CreateOnly {
args = append(args, "--create-only")
}
case OpRename:
args = append(args, "--to", p.To)
case OpUpload:
// The URL, size and digest are not secrets — only the token is, and it
// rides the environment below.
args = append(args, "--source-url", p.SourceURL,
"--size", strconv.FormatInt(p.UploadSize, 10), "--sha256", p.UploadSHA256)
if p.Overwrite {
args = append(args, "--overwrite")
}
}
container := corev1.Container{ container := corev1.Container{
Name: containerName, Name: containerName,
Image: p.Image, Image: p.Image,
@@ -185,16 +216,21 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
}, },
} }
// New content rides the Job spec as a base64 env var. felis-api cannot create a // New content rides the Job spec as base64 env vars (see ContentEnv for why
// Secret (it holds secrets:get only), so the spec is the sole channel into the // it is several). felis-api cannot create a Secret (it holds secrets:get only),
// Pod; base64 keeps arbitrary bytes — CRLF line endings, a UTF-8 BOM, a binary // so the spec is the sole channel into the Pod; base64 keeps arbitrary bytes —
// blob — intact through a field that must be a valid string. The env var is set // CRLF line endings, a UTF-8 BOM, a binary blob — intact through a field that
// ONLY for a write, so a list/read Job spec carries no caller content at all. // must be a valid string. Content is set ONLY for a write and the token ONLY
if p.Op == OpWrite { // for an upload, so no other Job spec carries either.
container.Env = []corev1.EnvVar{{ switch p.Op {
Name: ContentEnv, case OpWrite:
Value: base64.StdEncoding.EncodeToString(p.Content), parts := splitContent(p.Content)
}} container.Env = []corev1.EnvVar{{Name: ContentPartsEnv, Value: strconv.Itoa(len(parts))}}
for i, part := range parts {
container.Env = append(container.Env, corev1.EnvVar{Name: contentPartEnv(i), Value: part})
}
case OpUpload:
container.Env = []corev1.EnvVar{{Name: UploadTokenEnv, Value: p.UploadToken}}
} }
job := &batchv1.Job{ job := &batchv1.Job{
@@ -261,11 +297,12 @@ func int64Ptr(i int64) *int64 { return &i }
// filesCapabilities is what the root executor keeps after dropping ALL (see // filesCapabilities is what the root executor keeps after dropping ALL (see
// Config.RunAsUser). DAC_OVERRIDE opens a mode-0600 file (level.dat) the game wrote // Config.RunAsUser). DAC_OVERRIDE opens a mode-0600 file (level.dat) the game wrote
// as its own uid, which a fixed non-root uid could not. A write also keeps CHOWN so // as its own uid, which a fixed non-root uid could not. A write, mkdir or upload
// the file it creates can be handed to naming.GameUID (exec.go ownWritten); a list // also keeps CHOWN so what it creates can be handed to naming.GameUID (exec.go
// or read changes nothing and gets no more than it needs. // ownWritten). List, read, delete and rename create nothing and get no more than
// they need.
func filesCapabilities(op string) []corev1.Capability { func filesCapabilities(op string) []corev1.Capability {
if op == OpWrite { if op == OpWrite || op == OpMkdir || op == OpUpload {
return []corev1.Capability{"CHOWN", "DAC_OVERRIDE"} return []corev1.Capability{"CHOWN", "DAC_OVERRIDE"}
} }
return []corev1.Capability{"DAC_OVERRIDE"} return []corev1.Capability{"DAC_OVERRIDE"}
+171 -26
View File
@@ -1,7 +1,10 @@
package fileedit package fileedit
import ( import (
"bytes"
"encoding/base64" "encoding/base64"
"slices"
"strconv"
"strings" "strings"
"testing" "testing"
"time" "time"
@@ -9,6 +12,18 @@ import (
corev1 "k8s.io/api/core/v1" corev1 "k8s.io/api/core/v1"
) )
// opParams is testParams with what each op needs to render.
func opParams(op string) JobParams {
p := testParams(op)
switch op {
case OpRename:
p.To = "server.properties.bak"
case OpUpload:
p.SourceURL, p.UploadToken = "http://felis-api-internal.felis.svc:8081/api/v1/internal/file-uploads/0a", "tok"
}
return p
}
func testParams(op string) JobParams { func testParams(op string) JobParams {
return JobParams{ return JobParams{
Server: "survival", Server: "survival",
@@ -105,16 +120,28 @@ func TestFilesJobIsolation(t *testing.T) {
} }
}) })
// Only a write creates a file it must hand back to the game uid, so only a // The ops that create a file or folder hand it back to the game uid, so they
// write keeps CHOWN; a read stays at DAC_OVERRIDE alone (asserted above). // keep CHOWN; the rest stay at DAC_OVERRIDE alone.
t.Run("a write also keeps CHOWN", func(t *testing.T) { t.Run("only the creating ops keep CHOWN", func(t *testing.T) {
w, err := FilesJob(testParams(OpWrite)) for _, tc := range []struct {
op string
chown bool
}{
{OpList, false}, {OpRead, false}, {OpDelete, false}, {OpRename, false},
{OpWrite, true}, {OpMkdir, true}, {OpUpload, true},
} {
j, err := FilesJob(opParams(tc.op))
if err != nil { if err != nil {
t.Fatalf("FilesJob: %v", err) t.Fatalf("%s: FilesJob: %v", tc.op, err)
}
add := j.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add
want := []corev1.Capability{"DAC_OVERRIDE"}
if tc.chown {
want = []corev1.Capability{"CHOWN", "DAC_OVERRIDE"}
}
if !slices.Equal(add, want) {
t.Errorf("%s capabilities = %v, want %v", tc.op, add, want)
} }
add := w.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add
if len(add) != 2 || add[0] != "CHOWN" || add[1] != "DAC_OVERRIDE" {
t.Fatalf("write capabilities = %v, want [CHOWN DAC_OVERRIDE]", add)
} }
}) })
@@ -174,10 +201,10 @@ func TestFilesJobExpectArg(t *testing.T) {
} }
} }
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a // TestFilesJobWorldMountIsReadOnlyForReads pins the guarantee that list and read
// write can mutate a world. For list and read the kernel refuses the write, not // cannot mutate a world. For them the kernel refuses the write, not
// merely the code — a defence that survives a bug in the entrypoint. // merely the code — a defence that survives a bug in the entrypoint.
func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) { func TestFilesJobWorldMountIsReadOnlyForReads(t *testing.T) {
cases := []struct { cases := []struct {
op string op string
wantReadOnly bool wantReadOnly bool
@@ -185,10 +212,14 @@ func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
{OpList, true}, {OpList, true},
{OpRead, true}, {OpRead, true},
{OpWrite, false}, {OpWrite, false},
{OpMkdir, false},
{OpDelete, false},
{OpRename, false},
{OpUpload, false},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.op, func(t *testing.T) { t.Run(tc.op, func(t *testing.T) {
job, err := FilesJob(testParams(tc.op)) job, err := FilesJob(opParams(tc.op))
if err != nil { if err != nil {
t.Fatalf("FilesJob: %v", err) t.Fatalf("FilesJob: %v", err)
} }
@@ -203,11 +234,23 @@ func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
} }
} }
// envLookup reads a rendered container's environment the way the Job's process
// sees it.
func envLookup(env []corev1.EnvVar) func(string) (string, bool) {
return func(name string) (string, bool) {
for _, e := range env {
if e.Name == name {
return e.Value, true
}
}
return "", false
}
}
// TestFilesJobContentEnv pins the write channel: content rides the Job spec // TestFilesJobContentEnv pins the write channel: content rides the Job spec
// base64-encoded, and ONLY for a write — a list or read Job spec must carry no // base64-encoded, and ONLY for a write — no other Job spec carries caller content.
// caller content at all.
func TestFilesJobContentEnv(t *testing.T) { func TestFilesJobContentEnv(t *testing.T) {
t.Run("write carries base64 content", func(t *testing.T) { t.Run("write carries the content, reassembled by the entrypoint", func(t *testing.T) {
p := testParams(OpWrite) p := testParams(OpWrite)
p.Content = []byte("motd=hello\n\x00\xff") p.Content = []byte("motd=hello\n\x00\xff")
job, err := FilesJob(p) job, err := FilesJob(p)
@@ -215,15 +258,16 @@ func TestFilesJobContentEnv(t *testing.T) {
t.Fatalf("FilesJob: %v", err) t.Fatalf("FilesJob: %v", err)
} }
env := job.Spec.Template.Spec.Containers[0].Env env := job.Spec.Template.Spec.Containers[0].Env
if len(env) != 1 || env[0].Name != ContentEnv { want := []corev1.EnvVar{
t.Fatalf("env = %+v, want exactly %s", env, ContentEnv) {Name: ContentPartsEnv, Value: "1"},
{Name: ContentEnv + "_0", Value: base64.StdEncoding.EncodeToString(p.Content)},
} }
got, err := base64.StdEncoding.DecodeString(env[0].Value) if !slices.Equal(env, want) {
if err != nil { t.Fatalf("env = %+v, want %+v", env, want)
t.Fatalf("env value is not base64: %v", err)
} }
if string(got) != string(p.Content) { got, err := ContentFromEnv(envLookup(env))
t.Fatalf("decoded %q, want %q — arbitrary bytes must survive", got, p.Content) if err != nil || string(got) != string(p.Content) {
t.Fatalf("reassembled %q, %v; want %q — arbitrary bytes must survive", got, err, p.Content)
} }
// The content must never leak into argv, which is world-readable on the node. // The content must never leak into argv, which is world-readable on the node.
if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") { if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") {
@@ -231,9 +275,52 @@ func TestFilesJobContentEnv(t *testing.T) {
} }
}) })
for _, op := range []string{OpList, OpRead} { // execve refuses one environment string over 128 KiB and the container never
t.Run(op+" carries no content env", func(t *testing.T) { // starts, so the largest write must arrive in parts each well under it.
job, err := FilesJob(testParams(op)) t.Run("the largest write is split under the kernel's per-variable limit", func(t *testing.T) {
p := testParams(OpWrite)
p.Content = make([]byte, MaxWriteBytes)
for i := range p.Content {
p.Content[i] = byte(i * 7)
}
job, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
env := job.Spec.Template.Spec.Containers[0].Env
if len(env) != 1+maxContentParts || env[0].Value != strconv.Itoa(maxContentParts) {
t.Fatalf("%d variables, count %q; want the count and %d parts", len(env), env[0].Value, maxContentParts)
}
for _, e := range env {
if len(e.Value) > contentChunk || len(e.Name)+1+len(e.Value) >= 128<<10 {
t.Fatalf("%s is %d bytes; each part must fit in %d", e.Name, len(e.Value), contentChunk)
}
}
got, err := ContentFromEnv(envLookup(env))
if err != nil || !bytes.Equal(got, p.Content) {
t.Fatalf("reassembled %d bytes, %v; want the %d written", len(got), err, len(p.Content))
}
})
t.Run("an empty write is zero parts", func(t *testing.T) {
p := testParams(OpWrite)
p.Content = []byte{}
job, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
env := job.Spec.Template.Spec.Containers[0].Env
if want := []corev1.EnvVar{{Name: ContentPartsEnv, Value: "0"}}; !slices.Equal(env, want) {
t.Fatalf("env = %+v, want %+v", env, want)
}
if got, err := ContentFromEnv(envLookup(env)); err != nil || len(got) != 0 {
t.Fatalf("reassembled %q, %v; want empty", got, err)
}
})
for _, op := range []string{OpList, OpRead, OpMkdir, OpDelete, OpRename} {
t.Run(op+" carries no env", func(t *testing.T) {
job, err := FilesJob(opParams(op))
if err != nil { if err != nil {
t.Fatalf("FilesJob: %v", err) t.Fatalf("FilesJob: %v", err)
} }
@@ -244,6 +331,62 @@ func TestFilesJobContentEnv(t *testing.T) {
} }
} }
// TestFilesJobOpArgs pins what each op hands the entrypoint beyond --op and
// --path, and that the upload token rides the environment, never argv.
func TestFilesJobOpArgs(t *testing.T) {
args := func(p JobParams) []string {
t.Helper()
j, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob(%s): %v", p.Op, err)
}
return j.Spec.Template.Spec.Containers[0].Args[6:]
}
read, err := FilesJob(testParams(OpRead))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
if got, want := read.Spec.Template.Spec.Containers[0].Args, []string{"--op", "read", "--path", "server.properties", "--worlds-root", "/data"}; !slices.Equal(got, want) {
t.Fatalf("read args = %v, want %v", got, want)
}
create := testParams(OpWrite)
create.CreateOnly = true
if got := args(create); !slices.Equal(got, []string{"--create-only"}) {
t.Errorf("create-only write args = %v", got)
}
readCreate := testParams(OpRead)
readCreate.CreateOnly = true
if got := args(readCreate); len(got) != 0 {
t.Errorf("a read carries write flags: %v", got)
}
if got := args(opParams(OpRename)); !slices.Equal(got, []string{"--to", "server.properties.bak"}) {
t.Errorf("rename args = %v", got)
}
up := opParams(OpUpload)
up.UploadSize, up.UploadSHA256 = 1234, strings.Repeat("c", 64)
want := []string{"--source-url", up.SourceURL, "--size", "1234", "--sha256", strings.Repeat("c", 64)}
if got := args(up); !slices.Equal(got, want) {
t.Errorf("upload args = %v, want %v", got, want)
}
up.Overwrite = true
if got := args(up); !slices.Equal(got, append(want, "--overwrite")) {
t.Errorf("overwriting upload args = %v", got)
}
j, err := FilesJob(up)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
c := j.Spec.Template.Spec.Containers[0]
if want := []corev1.EnvVar{{Name: UploadTokenEnv, Value: "tok"}}; !slices.Equal(c.Env, want) {
t.Errorf("upload env = %+v, want %+v", c.Env, want)
}
if slices.Contains(c.Args, "tok") {
t.Errorf("the upload token is in argv: %v", c.Args)
}
}
// TestFilesJobNameIsPerInvocation is the RBAC-forced property documented on // TestFilesJobNameIsPerInvocation is the RBAC-forced property documented on
// FilesJobName. felis-api holds jobs:create and NOTHING else — no jobs:delete — so // FilesJobName. felis-api holds jobs:create and NOTHING else — no jobs:delete — so
// a deterministic name would let the first completed Job squat it for a whole TTL // a deterministic name would let the first completed Job squat it for a whole TTL
@@ -285,7 +428,9 @@ func TestFilesJobRejectsBadParams(t *testing.T) {
{"no image", func(p *JobParams) { p.Image = "" }}, {"no image", func(p *JobParams) { p.Image = "" }},
{"no world PVC", func(p *JobParams) { p.WorldPVC = "" }}, {"no world PVC", func(p *JobParams) { p.WorldPVC = "" }},
{"no op id", func(p *JobParams) { p.OpID = "" }}, {"no op id", func(p *JobParams) { p.OpID = "" }},
{"unknown op", func(p *JobParams) { p.Op = "delete" }}, {"unknown op", func(p *JobParams) { p.Op = "chmod" }},
{"upload without a source", func(p *JobParams) { p.Op, p.UploadToken = OpUpload, "tok" }},
{"upload without a token", func(p *JobParams) { p.Op, p.SourceURL = OpUpload, "http://x" }},
{"oversized content", func(p *JobParams) { {"oversized content", func(p *JobParams) {
p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1) p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1)
}}, }},
+580
View File
@@ -0,0 +1,580 @@
package fileedit
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
// mustRead returns a file's content, failing the test if it is not there.
func mustRead(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
return string(b)
}
// assertAbsent fails if anything, a dangling link included, is at p.
func assertAbsent(t *testing.T, p string) {
t.Helper()
if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s exists (%v), want nothing there", p, err)
}
}
func symlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
// exec runs one request and fails the test on an infrastructure error.
func exec(t *testing.T, root string, req Request) Result {
t.Helper()
res, err := Execute(root, req)
if err != nil {
t.Fatalf("Execute(%+v): %v", req, err)
}
return res
}
// TestWriteCreateOnly: the panel's "new file" lands only where nothing is.
func TestWriteCreateOnly(t *testing.T) {
root, _ := worldRoot(t)
create := func(name string) Result {
return exec(t, root, Request{Op: OpWrite, Path: name, Content: []byte("new: true\n"), CreateOnly: true})
}
t.Run("a free path is created", func(t *testing.T) {
res := create("config/new.yml")
if res.Code != "" || res.SHA256 != digest([]byte("new: true\n")) {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "config", "new.yml")); got != "new: true\n" {
t.Fatalf("content = %q", got)
}
})
t.Run("an existing file is refused and left alone", func(t *testing.T) {
if res := create("server.properties"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
})
t.Run("a folder is refused as existing", func(t *testing.T) {
if res := create("config"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
})
// A plain write follows a link inside the root and creates what it names; a
// create must not, or "new file" would land somewhere the caller never named.
t.Run("a dangling link is refused, never followed", func(t *testing.T) {
symlink(t, "config/elsewhere.yml", filepath.Join(root, "dangling.yml"))
if res := create("dangling.yml"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
assertAbsent(t, filepath.Join(root, "config", "elsewhere.yml"))
})
}
func TestMkdir(t *testing.T) {
root, outside := worldRoot(t)
mkdir := func(name string) Result { return exec(t, root, Request{Op: OpMkdir, Path: name}) }
t.Run("makes the folder and hands it to the game uid", func(t *testing.T) {
var owned []string
prev := ownWritten
ownWritten = func(_ *os.Root, name string) error {
owned = append(owned, name)
return os.ErrPermission
}
defer func() { ownWritten = prev }()
if res := mkdir("config/sub/"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if info, err := os.Lstat(filepath.Join(root, "config", "sub")); err != nil || !info.IsDir() {
t.Fatalf("config/sub = %v, %v; want a folder", info, err)
}
if len(owned) != 1 || owned[0] != "config/sub" {
t.Fatalf("owned = %v, want [config/sub]", owned)
}
})
t.Run("an existing name is exists", func(t *testing.T) {
for _, name := range []string{"config", "server.properties"} {
if res := mkdir(name); res.Code != CodeExists {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeExists)
}
}
})
t.Run("a missing parent is not_found and is not made", func(t *testing.T) {
res := mkdir("plugins/Essentials")
if res.Code != CodeNotFound || res.Error != "folder plugins does not exist" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
t.Run("the root itself is bad_path", func(t *testing.T) {
for _, name := range []string{"", ".", "./", "config/.."} {
if res := mkdir(name); res.Code != CodeBadPath || res.Error != "a folder needs a name" {
t.Errorf("%q: result = %+v", name, res)
}
}
})
t.Run("an escape is bad_path and makes nothing outside", func(t *testing.T) {
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, name := range []string{"../outside/made", "escape-link/made", filepath.Join(outside, "made")} {
if res := mkdir(name); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(outside, "made"))
})
}
func TestRemove(t *testing.T) {
root, outside := worldRoot(t)
remove := func(name string) Result { return exec(t, root, Request{Op: OpDelete, Path: name}) }
t.Run("a file", func(t *testing.T) {
if res := remove("config/paper.yml"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a folder with everything in it", func(t *testing.T) {
deep := filepath.Join(root, "plugins", "Essentials")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deep, "config.yml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if res := remove("plugins"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
// A link is removed itself. With a trailing slash the kernel would resolve
// it to the folder it names, whose contents would then go instead.
t.Run("a link, never what it points at", func(t *testing.T) {
keep := filepath.Join(root, "keep")
if err := os.MkdirAll(keep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(keep, "a.txt"), []byte("kept"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "keep", filepath.Join(root, "keep-link"))
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, name := range []string{"keep-link/", "escape-link"} {
if res := remove(name); res.Code != "" {
t.Fatalf("%s: result = %+v", name, res)
}
assertAbsent(t, filepath.Join(root, strings.TrimSuffix(name, "/")))
}
if got := mustRead(t, filepath.Join(keep, "a.txt")); got != "kept" {
t.Fatalf("keep/a.txt = %q", got)
}
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("the root itself is bad_path", func(t *testing.T) {
for _, name := range []string{"", ".", "./", "config/.."} {
if res := remove(name); res.Code != CodeBadPath {
t.Errorf("%q: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
mustRead(t, filepath.Join(root, "server.properties"))
})
t.Run("an escape is bad_path and deletes nothing outside", func(t *testing.T) {
symlink(t, outside, filepath.Join(root, "escape-dir"))
for _, name := range []string{"../outside/secret.txt", "escape-dir/secret.txt", "../outside"} {
if res := remove(name); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("a missing path is not_found", func(t *testing.T) {
if res := remove("absent.txt"); res.Code != CodeNotFound {
t.Fatalf("code = %q, want %q", res.Code, CodeNotFound)
}
})
}
func TestRename(t *testing.T) {
rename := func(t *testing.T, root, from, to string) Result {
return exec(t, root, Request{Op: OpRename, Path: from, To: to})
}
t.Run("a file moves", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "paper.yml"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "paper.yml")); got != "verbose: false\n" {
t.Fatalf("paper.yml = %q", got)
}
assertAbsent(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a folder moves with its contents", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.MkdirAll(filepath.Join(root, "plugins"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "plugins", "a.jar"), []byte("jar"), 0o644); err != nil {
t.Fatal(err)
}
if res := rename(t, root, "plugins/", "plugins-off"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "plugins-off", "a.jar")); got != "jar" {
t.Fatalf("plugins-off/a.jar = %q", got)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
// A trailing slash would make the kernel act on what a link names; the link is
// what was asked for.
t.Run("a link moves itself, never what it names", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.MkdirAll(filepath.Join(root, "keep"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "keep", "a.jar"), []byte("jar"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "keep", filepath.Join(root, "keep-link"))
if res := rename(t, root, "keep-link/", "moved-link"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if info, err := os.Lstat(filepath.Join(root, "moved-link")); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("moved-link = %v, %v; want the link", info, err)
}
if got := mustRead(t, filepath.Join(root, "keep", "a.jar")); got != "jar" {
t.Fatalf("keep/a.jar = %q", got)
}
assertAbsent(t, filepath.Join(root, "keep-link"))
})
t.Run("an existing destination is exists and both stay", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "server.properties"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties = %q", got)
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a missing destination folder is not_found and is not made", func(t *testing.T) {
root, _ := worldRoot(t)
res := rename(t, root, "config/paper.yml", "disabled/paper.yml")
if res.Code != CodeNotFound || res.Error != "folder disabled does not exist" {
t.Fatalf("result = %+v", res)
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
assertAbsent(t, filepath.Join(root, "disabled"))
})
t.Run("a missing source is not_found", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "absent.txt", "b.txt"); res.Code != CodeNotFound {
t.Fatalf("code = %q, want %q", res.Code, CodeNotFound)
}
})
t.Run("the root is bad_path either way", func(t *testing.T) {
root, _ := worldRoot(t)
for _, tc := range [][2]string{
{".", "x"}, {"", "x"}, {"./", "x"},
{"config/paper.yml", "."}, {"config/paper.yml", "./"}, {"config/paper.yml", "config/.."},
} {
if res := rename(t, root, tc[0], tc[1]); res.Code != CodeBadPath {
t.Errorf("%q -> %q: code = %q, want %q", tc[0], tc[1], res.Code, CodeBadPath)
}
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
})
// read withholds these by name, so under another name they would come back
// whole. Every spelling of them, and every way of reaching them, is refused.
t.Run("the paths read guards cannot move", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.WriteFile(filepath.Join(root, "config", "paper-global.yml"), []byte("secret: k"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "config", filepath.Join(root, "cfg-link"))
for _, from := range []string{
"server.properties", "./server.properties",
"config/paper-global.yml", "config//paper-global.yml",
"config", "config/", "./config",
"cfg-link/paper-global.yml",
} {
res := rename(t, root, from, "moved")
if res.Code != CodeBadPath || !strings.Contains(res.Error, "managed by felis") {
t.Errorf("%s: result = %+v, want the managed refusal", from, res)
}
}
assertAbsent(t, filepath.Join(root, "moved"))
mustRead(t, filepath.Join(root, "config", "paper-global.yml"))
mustRead(t, filepath.Join(root, "server.properties"))
})
// When the guarded name is itself a link, what it names is guarded too, and so
// is the link.
t.Run("the target of a guarded link cannot move", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.Rename(filepath.Join(root, "config"), filepath.Join(root, "real-config")); err != nil {
t.Fatal(err)
}
symlink(t, "real-config", filepath.Join(root, "config"))
if err := os.Rename(filepath.Join(root, "server.properties"), filepath.Join(root, "real.properties")); err != nil {
t.Fatal(err)
}
symlink(t, "real.properties", filepath.Join(root, "server.properties"))
// The links themselves too: under another name, a read would follow one to
// what it guards.
for _, from := range []string{"real-config", "real.properties", "config", "server.properties"} {
if res := rename(t, root, from, "moved"); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", from, res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(root, "moved"))
})
t.Run("a neighbour of a guarded path still moves", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "config/paper.yml.bak"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
})
t.Run("an escape is bad_path either way", func(t *testing.T) {
root, outside := worldRoot(t)
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, tc := range [][2]string{
{"../outside/secret.txt", "stolen.txt"},
{"escape-link/secret.txt", "stolen.txt"},
{"config/paper.yml", "../outside/planted.yml"},
{"config/paper.yml", "escape-link/planted.yml"},
} {
if res := rename(t, root, tc[0], tc[1]); res.Code != CodeBadPath {
t.Errorf("%s -> %s: code = %q, want %q", tc[0], tc[1], res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(root, "stolen.txt"))
assertAbsent(t, filepath.Join(outside, "planted.yml"))
mustRead(t, filepath.Join(root, "config", "paper.yml"))
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
}
// fakeSource is an upload's bytes as the Job would fetch them.
type fakeSource struct {
body string
opened int
err error // returned by Open
readErr error // returned by the body once it runs out
}
func (s *fakeSource) upload(size int64, sum string) *Upload {
return &Upload{Size: size, SHA256: sum, Open: func() (io.ReadCloser, error) {
s.opened++
if s.err != nil {
return nil, s.err
}
var r io.Reader = strings.NewReader(s.body)
if s.readErr != nil {
r = io.MultiReader(r, errReader{s.readErr})
}
return io.NopCloser(r), nil
}}
}
type errReader struct{ err error }
func (e errReader) Read([]byte) (int, error) { return 0, e.err }
func TestUpload(t *testing.T) {
const jar = "PK\x03\x04 plugin bytes"
whole := func(s *fakeSource) *Upload { return s.upload(int64(len(s.body)), digest([]byte(s.body))) }
send := func(t *testing.T, root, name string, u *Upload, overwrite bool) (Result, error) {
t.Helper()
return Execute(root, Request{Op: OpUpload, Path: name, Upload: u, Overwrite: overwrite})
}
t.Run("lands the bytes as a new file", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "config/Geyser.jar", whole(src), false)
if err != nil || res.Code != "" {
t.Fatalf("result = %+v, %v", res, err)
}
if got := mustRead(t, filepath.Join(root, "config", "Geyser.jar")); got != jar {
t.Fatalf("content = %q", got)
}
info, _ := os.Stat(filepath.Join(root, "config", "Geyser.jar"))
if info.Mode().Perm() != 0o644 {
t.Fatalf("mode = %v, want 0644", info.Mode().Perm())
}
assertNoTemporaries(t, filepath.Join(root, "config"))
})
t.Run("an existing path is exists and the bytes are never fetched", func(t *testing.T) {
root, _ := worldRoot(t)
symlink(t, "config/elsewhere.jar", filepath.Join(root, "dangling.jar"))
for _, name := range []string{"server.properties", "dangling.jar"} {
src := &fakeSource{body: jar}
res, err := send(t, root, name, whole(src), false)
if err != nil || res.Code != CodeExists || src.opened != 0 {
t.Fatalf("%s: result = %+v, %v, opened %d; want exists and no fetch", name, res, err, src.opened)
}
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties = %q", got)
}
assertAbsent(t, filepath.Join(root, "config", "elsewhere.jar"))
})
t.Run("overwrite replaces the file and keeps its mode", func(t *testing.T) {
root, _ := worldRoot(t)
props := filepath.Join(root, "server.properties")
if err := os.Chmod(props, 0o600); err != nil {
t.Fatal(err)
}
res, err := send(t, root, "server.properties", whole(&fakeSource{body: jar}), true)
if err != nil || res.Code != "" {
t.Fatalf("result = %+v, %v", res, err)
}
if got := mustRead(t, props); got != jar {
t.Fatalf("content = %q", got)
}
if info, _ := os.Stat(props); info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600 kept", info.Mode().Perm())
}
})
t.Run("a folder is bad_path and the bytes are never fetched", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "config", whole(src), true)
if err != nil || res.Code != CodeBadPath || src.opened != 0 {
t.Fatalf("result = %+v, %v, opened %d", res, err, src.opened)
}
})
t.Run("over the cap is too_large and never fetched; at the cap is fetched", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "big.jar", src.upload(MaxUploadBytes+1, ""), false)
if err != nil || res.Code != CodeTooLarge || src.opened != 0 {
t.Fatalf("result = %+v, %v, opened %d", res, err, src.opened)
}
// At the cap the size passes and the transfer starts; this source then
// comes up short, which is a broken transfer rather than a refusal.
if _, err := send(t, root, "big.jar", src.upload(MaxUploadBytes, ""), false); err == nil || src.opened != 1 {
t.Fatalf("at the cap: err = %v, opened %d; want a fetch", err, src.opened)
}
})
// Anything that says the bytes did not arrive intact is the Job failing, not a
// caller mistake, and whatever was at the path stays exactly as it was.
t.Run("a broken transfer is an error and changes nothing", func(t *testing.T) {
for name, u := range map[string]func(*fakeSource) *Upload{
"short": func(s *fakeSource) *Upload { return s.upload(int64(len(jar))+1, digest([]byte(jar))) },
"long": func(s *fakeSource) *Upload { return s.upload(int64(len(jar))-1, digest([]byte(jar[:len(jar)-1]))) },
"wrong sha256": func(s *fakeSource) *Upload { return s.upload(int64(len(jar)), digest([]byte("other"))) },
"open fails": func(s *fakeSource) *Upload { s.err = errors.New("connection refused"); return whole(s) },
"source breaks": func(s *fakeSource) *Upload { s.readErr = errors.New("connection reset"); return whole(s) },
// The source's own error must not read as the volume filling up.
"source ENOSPC": func(s *fakeSource) *Upload { s.readErr = syscall.ENOSPC; return whole(s) },
} {
t.Run(name, func(t *testing.T) {
root, _ := worldRoot(t)
res, err := send(t, root, "server.properties", u(&fakeSource{body: jar}), true)
var te *transferError
if !errors.As(err, &te) || res.Code != "" {
t.Fatalf("result = %+v, err = %v; want a transfer error", res, err)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
assertNoTemporaries(t, root)
})
}
})
t.Run("a full volume is no_space and changes nothing", func(t *testing.T) {
root, _ := worldRoot(t)
prev := syncWritten
syncWritten = func(*os.File) error { return syscall.ENOSPC }
defer func() { syncWritten = prev }()
res, err := send(t, root, "server.properties", whole(&fakeSource{body: jar}), true)
if err != nil || res.Code != CodeNoSpace {
t.Fatalf("result = %+v, %v; want no_space", res, err)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
assertNoTemporaries(t, root)
})
t.Run("an escape is bad_path and never fetched", func(t *testing.T) {
root, outside := worldRoot(t)
symlink(t, outside, filepath.Join(root, "escape-link"))
symlink(t, "../../outside/secret.txt", filepath.Join(root, "config", "climb"))
for _, name := range []string{"../outside/x.jar", "escape-link/x.jar", "config/climb"} {
src := &fakeSource{body: jar}
res, err := send(t, root, name, whole(src), true)
if err != nil || res.Code != CodeBadPath || src.opened != 0 {
t.Errorf("%s: result = %+v, %v, opened %d", name, res, err, src.opened)
}
}
assertAbsent(t, filepath.Join(outside, "x.jar"))
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("no source is an error", func(t *testing.T) {
root, _ := worldRoot(t)
if _, err := send(t, root, "x.jar", nil, false); err == nil {
t.Fatal("an upload without a source must fail")
}
})
}
func TestExecuteRefusesAnUnknownOp(t *testing.T) {
root, _ := worldRoot(t)
if _, err := Execute(root, Request{Op: "chmod", Path: "server.properties"}); err == nil {
t.Fatal("an unknown op must fail")
}
}
+254
View File
@@ -0,0 +1,254 @@
package fileedit
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"sync"
"syscall"
)
// Stage holds uploads between the request that brought them and the Job that
// lands them. The bytes cannot ride the Job spec the way an edit does (etcd caps
// an object near 1.5 MiB, and execve one environment string at 128 KiB), and
// felis-api cannot mount the world volume, so felis-api keeps the upload on its
// own disk and serves it once, on its internal face, to the Job it created for it
// (cmd/felis files, fetchUpload).
//
// Each staged upload is opened by an unguessable id in the URL plus a token the
// Job carries in its environment. Only a digest of the token is kept, compared in
// constant time, and the first successful Open spends it: the Job never retries,
// so a second Open could only be someone else. The release func Put returns
// deletes the file once the Job has answered, whatever it answered.
//
// Nothing here outlives the process: the index is in memory, so Sweep empties
// Dir at startup of whatever a previous process left behind.
type Stage struct {
// Dir holds the staged files. cmd/felis puts it on the uploads volume, which
// has a real capacity; the pod's /tmp is the node's own disk.
Dir string
// MinFree is the share of Dir's filesystem an upload must leave free
// (DefaultStageMinFree when zero), so a burst of uploads cannot fill the disk
// the submission store shares.
MinFree float64
mu sync.Mutex
items map[string]*stagedFile
reserved int64
}
// DefaultStageMinFree matches the submission store's own floor
// (submit.DefaultUploadsMinFree): the two share the uploads volume.
const DefaultStageMinFree = 0.10
type stagedFile struct {
path string
tokenHash [sha256.Size]byte
size int64
used bool
}
// Staged is one upload on the stage: where the Job fetches it and what it must
// be.
type Staged struct {
ID string
Token string
SHA256 string
Size int64
}
var (
// ErrStageFull is an upload that would leave less than MinFree of the staging
// filesystem free, or that ran it out of space outright.
ErrStageFull = errors.New("fileedit: no room to stage the upload")
// ErrShortUpload is a body that ended, or broke, before its declared length.
ErrShortUpload = errors.New("fileedit: the upload ended before its declared length")
// ErrNotStaged is an Open with an unknown id, a wrong token, or a spent one.
// They are one error on purpose: the internal face answers all three the same.
ErrNotStaged = errors.New("fileedit: no such staged upload")
)
// statfs reports a filesystem's available and total bytes. A var so a test can
// stage against a disk of a chosen size.
var statfs = func(dir string) (avail, total uint64, err error) {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return 0, 0, err
}
bsize := uint64(st.Bsize) // uint32 on darwin
return uint64(st.Bavail) * bsize, uint64(st.Blocks) * bsize, nil
}
// Sweep deletes everything under Dir. Call it once, before the first Put.
func (s *Stage) Sweep() error {
if err := os.RemoveAll(s.Dir); err != nil {
return fmt.Errorf("fileedit: clear the upload stage: %w", err)
}
return nil
}
// Put stages exactly size bytes from body and returns the handle a Job fetches
// it by, plus the func that deletes it. body must end right after size bytes (an
// HTTP body with that Content-Length does): Put reads to its end, which is also
// what tells the server the body is done.
func (s *Stage) Put(body io.Reader, size int64) (Staged, func(), error) {
if size < 0 {
return Staged{}, nil, fmt.Errorf("fileedit: an upload of %d bytes", size)
}
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
return Staged{}, nil, fmt.Errorf("fileedit: create the upload stage: %w", err)
}
if err := s.reserve(size); err != nil {
return Staged{}, nil, err
}
defer s.unreserve(size)
f, err := os.CreateTemp(s.Dir, "upload-*")
if err != nil {
return Staged{}, nil, fmt.Errorf("fileedit: stage the upload: %w", err)
}
h := sha256.New()
src := &bodyReader{r: body}
// One byte past size, so the read that finds the end happens here.
n, copyErr := io.Copy(io.MultiWriter(f, h), io.LimitReader(src, size+1))
closeErr := f.Close()
if err := stageFailure(src.err, copyErr, closeErr, n, size); err != nil {
os.Remove(f.Name())
return Staged{}, nil, err
}
st, tokenHash, err := newHandle(h, size)
if err != nil {
os.Remove(f.Name())
return Staged{}, nil, err
}
s.mu.Lock()
if s.items == nil {
s.items = map[string]*stagedFile{}
}
s.items[st.ID] = &stagedFile{path: f.Name(), tokenHash: tokenHash, size: size}
s.mu.Unlock()
release := func() {
s.mu.Lock()
delete(s.items, st.ID)
s.mu.Unlock()
os.Remove(f.Name())
}
return st, release, nil
}
// stageFailure decides what a finished copy means. The body's own error, or a
// body shorter than promised, is the caller's; a body longer than promised
// cannot come through net/http, which stops at Content-Length, but a direct
// caller could send one and it is refused all the same.
func stageFailure(readErr, copyErr, closeErr error, n, size int64) error {
switch {
case readErr != nil:
return fmt.Errorf("%w: %v", ErrShortUpload, readErr)
case copyErr == nil && n < size:
return fmt.Errorf("%w: got %d of %d bytes", ErrShortUpload, n, size)
case copyErr == nil && n > size:
return fmt.Errorf("fileedit: the upload is longer than its declared %d bytes", size)
}
err := copyErr
if err == nil {
err = closeErr
}
if err == nil {
return nil
}
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) {
return fmt.Errorf("%w: %v", ErrStageFull, err)
}
return fmt.Errorf("fileedit: stage the upload: %w", err)
}
// newHandle mints the id and token for a staged upload whose bytes h hashed.
func newHandle(h hash.Hash, size int64) (Staged, [sha256.Size]byte, error) {
var id [16]byte
var token [32]byte
if _, err := rand.Read(id[:]); err != nil {
return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload id: %w", err)
}
if _, err := rand.Read(token[:]); err != nil {
return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload token: %w", err)
}
st := Staged{
ID: hex.EncodeToString(id[:]),
Token: hex.EncodeToString(token[:]),
SHA256: hex.EncodeToString(h.Sum(nil)),
Size: size,
}
return st, sha256.Sum256([]byte(st.Token)), nil
}
// reserve admits an upload of size bytes if the disk keeps MinFree free after it
// and after every upload still being written. Those have not reached the disk
// yet, so statfs alone would let two of them through on room for one.
func (s *Stage) reserve(size int64) error {
avail, total, err := statfs(s.Dir)
if err != nil {
return fmt.Errorf("fileedit: measure the upload stage: %w", err)
}
minFree := s.MinFree
if minFree <= 0 {
minFree = DefaultStageMinFree
}
floor := uint64(float64(total) * minFree)
s.mu.Lock()
defer s.mu.Unlock()
need := uint64(s.reserved) + uint64(size)
if avail < need || avail-need < floor {
return fmt.Errorf("%w: %d MiB free of %d MiB, and staging %d MiB would leave less than %.0f%% free",
ErrStageFull, avail>>20, total>>20, need>>20, minFree*100)
}
s.reserved += size
return nil
}
func (s *Stage) unreserve(size int64) {
s.mu.Lock()
s.reserved -= size
s.mu.Unlock()
}
// Open spends a staged upload's token and returns its file and size. Any
// mismatch is ErrNotStaged.
func (s *Stage) Open(id, token string) (*os.File, int64, error) {
sum := sha256.Sum256([]byte(token))
s.mu.Lock()
it, ok := s.items[id]
if !ok || it.used || subtle.ConstantTimeCompare(sum[:], it.tokenHash[:]) != 1 {
s.mu.Unlock()
return nil, 0, ErrNotStaged
}
it.used = true
s.mu.Unlock()
f, err := os.Open(it.path)
if err != nil {
return nil, 0, fmt.Errorf("fileedit: open the staged upload: %w", err)
}
return f, it.size, nil
}
// bodyReader remembers the body's own read error, so Put can tell a client that
// went away from the disk filling up.
type bodyReader struct {
r io.Reader
err error
}
func (b *bodyReader) Read(p []byte) (int, error) {
n, err := b.r.Read(p)
if err != nil && err != io.EOF {
b.err = err
}
return n, err
}
+279
View File
@@ -0,0 +1,279 @@
package fileedit
import (
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"testing"
)
// stubStatfs makes every staging disk report avail of total bytes free.
func stubStatfs(t *testing.T, avail, total uint64) {
t.Helper()
prev := statfs
statfs = func(string) (uint64, uint64, error) { return avail, total, nil }
t.Cleanup(func() { statfs = prev })
}
// roomyStage is a stage on a disk with room for anything a test stages.
func roomyStage(t *testing.T) *Stage {
t.Helper()
stubStatfs(t, 1<<40, 1<<41)
return &Stage{Dir: filepath.Join(t.TempDir(), "stage")}
}
func stagedNames(t *testing.T, s *Stage) []string {
t.Helper()
des, err := os.ReadDir(s.Dir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
var names []string
for _, de := range des {
names = append(names, de.Name())
}
return names
}
var hexID = regexp.MustCompile(`^[0-9a-f]{32}$`)
var hexToken = regexp.MustCompile(`^[0-9a-f]{64}$`)
// TestStageOpensOnce: a staged upload opens once, for the token minted with it,
// and a wrong token does not spend it.
func TestStageOpensOnce(t *testing.T) {
s := roomyStage(t)
const body = "PK\x03\x04 staged"
st, release, err := s.Put(strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("Put: %v", err)
}
if !hexID.MatchString(st.ID) || !hexToken.MatchString(st.Token) ||
st.Size != int64(len(body)) || st.SHA256 != digest([]byte(body)) {
t.Fatalf("staged = %+v", st)
}
other, releaseOther, err := s.Put(strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("Put: %v", err)
}
defer releaseOther()
if other.ID == st.ID || other.Token == st.Token {
t.Fatal("two uploads share an id or a token")
}
for name, try := range map[string][2]string{
"unknown id": {strings.Repeat("0", 32), st.Token},
"wrong token": {st.ID, strings.Repeat("0", 64)},
"another upload's token": {st.ID, other.Token},
"no token": {st.ID, ""},
} {
if f, _, err := s.Open(try[0], try[1]); !errors.Is(err, ErrNotStaged) {
if f != nil {
f.Close()
}
t.Fatalf("%s: err = %v, want ErrNotStaged", name, err)
}
}
f, size, err := s.Open(st.ID, st.Token)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, _ := io.ReadAll(f)
f.Close()
if string(got) != body || size != int64(len(body)) {
t.Fatalf("opened %q (%d bytes), want %q", got, size, body)
}
if _, _, err := s.Open(st.ID, st.Token); !errors.Is(err, ErrNotStaged) {
t.Fatalf("second Open: err = %v, want ErrNotStaged", err)
}
release()
if names := stagedNames(t, s); len(names) != 1 {
t.Fatalf("after release: %v, want only the other upload", names)
}
if _, _, err := s.Open(other.ID, other.Token); err != nil {
t.Fatalf("releasing one upload spent another: %v", err)
}
}
// Staged uploads are other people's files, so neither the folder nor the file
// is readable by anyone but felis-api's own uid.
func TestStageIsPrivate(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(strings.NewReader("x"), 1)
if err != nil {
t.Fatalf("Put: %v", err)
}
defer release()
dir, err := os.Stat(s.Dir)
if err != nil || dir.Mode().Perm() != 0o700 {
t.Fatalf("stage folder = %v, %v; want 0700", dir.Mode().Perm(), err)
}
names := stagedNames(t, s)
file, err := os.Stat(filepath.Join(s.Dir, names[0]))
if err != nil || file.Mode().Perm() != 0o600 {
t.Fatalf("staged file = %v, %v; want 0600", file.Mode().Perm(), err)
}
}
// eofReader serves body and records whether it was read to its end.
type eofReader struct {
r io.Reader
hitEOF bool
}
func (e *eofReader) Read(p []byte) (int, error) {
n, err := e.r.Read(p)
if err == io.EOF {
e.hitEOF = true
}
return n, err
}
// Put reads the body to its end: net/http's body deadline is lifted only then
// (withBodyDeadline), and a request whose body was not finished would otherwise
// be cut off under the handler still landing it.
func TestStagePutReadsToTheEnd(t *testing.T) {
s := roomyStage(t)
body := &eofReader{r: strings.NewReader("abc")}
_, release, err := s.Put(body, 3)
if err != nil {
t.Fatalf("Put: %v", err)
}
defer release()
if !body.hitEOF {
t.Fatal("Put stopped at the declared size without reading the end of the body")
}
}
func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
for name, tc := range map[string]struct {
body io.Reader
size int64
short bool
}{
"ends early": {strings.NewReader("abc"), 5, true},
"breaks": {io.MultiReader(strings.NewReader("ab"), errReader{io.ErrUnexpectedEOF}), 5, true},
// The client's own failure is a short upload, whatever errno it carries.
"breaks with ENOSPC": {io.MultiReader(strings.NewReader("ab"), errReader{syscall.ENOSPC}), 5, true},
"runs long": {strings.NewReader("abcdef"), 3, false},
} {
t.Run(name, func(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(tc.body, tc.size)
if err == nil {
release()
t.Fatal("Put accepted it")
}
if errors.Is(err, ErrShortUpload) != tc.short || errors.Is(err, ErrStageFull) {
t.Fatalf("err = %v, want short upload = %v", err, tc.short)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("left behind: %v", names)
}
})
}
if _, _, err := roomyStage(t).Put(strings.NewReader(""), -1); err == nil {
t.Fatal("a negative size was accepted")
}
}
// TestStageKeepsItsFloor: an upload is refused if it would leave less than
// MinFree of the disk free, counting uploads still arriving.
func TestStageKeepsItsFloor(t *testing.T) {
put := func(s *Stage, size int64) error {
_, release, err := s.Put(strings.NewReader(strings.Repeat("x", int(size))), size)
if err == nil {
release()
}
return err
}
t.Run("exactly at the floor is allowed, one byte past is not", func(t *testing.T) {
stubStatfs(t, 1000, 1200) // floor 600 at MinFree 0.5: room for 400
s := &Stage{Dir: t.TempDir(), MinFree: 0.5}
if err := put(s, 400); err != nil {
t.Fatalf("400 bytes: %v", err)
}
if err := put(s, 401); !errors.Is(err, ErrStageFull) {
t.Fatalf("401 bytes: err = %v, want ErrStageFull", err)
}
})
t.Run("an unset MinFree is the default", func(t *testing.T) {
stubStatfs(t, 1400, 10000) // floor 1000 at 10%: room for 400
s := &Stage{Dir: t.TempDir()}
if err := put(s, 400); err != nil {
t.Fatalf("400 bytes: %v", err)
}
if err := put(s, 401); !errors.Is(err, ErrStageFull) {
t.Fatalf("401 bytes: err = %v, want ErrStageFull", err)
}
})
t.Run("more than is free at all", func(t *testing.T) {
stubStatfs(t, 300, 1<<40)
s := &Stage{Dir: t.TempDir(), MinFree: 1e-12}
if err := put(s, 301); !errors.Is(err, ErrStageFull) {
t.Fatalf("err = %v, want ErrStageFull", err)
}
})
// statfs cannot see an upload still arriving, so the reservation is what keeps
// two of them from passing on room for one.
t.Run("an upload in flight holds its room", func(t *testing.T) {
stubStatfs(t, 1000, 1200) // room for 400
s := &Stage{Dir: t.TempDir(), MinFree: 0.5}
pr, pw := io.Pipe()
done := make(chan error, 1)
go func() {
_, release, err := s.Put(pr, 300)
if err == nil {
release()
}
done <- err
}()
// The write returns once Put is copying, which is after it reserved.
if _, err := pw.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := put(s, 200); !errors.Is(err, ErrStageFull) {
t.Fatalf("second upload beside one in flight: err = %v, want ErrStageFull", err)
}
if _, err := pw.Write([]byte(strings.Repeat("x", 299))); err != nil {
t.Fatal(err)
}
pw.Close()
if err := <-done; err != nil {
t.Fatalf("the upload in flight: %v", err)
}
if err := put(s, 200); err != nil {
t.Fatalf("after the first finished: %v", err)
}
})
}
func TestStageSweep(t *testing.T) {
s := roomyStage(t)
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Dir, "upload-left-by-a-crash"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := s.Sweep(); err != nil {
t.Fatalf("Sweep: %v", err)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("after Sweep: %v", names)
}
if _, release, err := s.Put(strings.NewReader("x"), 1); err != nil {
t.Fatalf("Put after Sweep: %v", err)
} else {
release()
}
}
+13 -9
View File
@@ -54,8 +54,9 @@ const (
// their own copies; maintenance_test pins them against these). // their own copies; maintenance_test pins them against these).
LabelServer = "felis.lolicon.best/server" LabelServer = "felis.lolicon.best/server"
LabelManagedBy = "app.kubernetes.io/managed-by" LabelManagedBy = "app.kubernetes.io/managed-by"
// LabelFilesMode is the file-editor operation (list, read, write) a files Job // LabelFilesMode is the file operation (list, read, write, mkdir, delete,
// performs. Only write holds the volume. // rename, upload) a files Job performs. Every one but list and read holds the
// volume.
LabelFilesMode = "felis.lolicon.best/files-mode" LabelFilesMode = "felis.lolicon.best/files-mode"
// LabelThenRestore marks a backup Job that is the safety snapshot in front of // LabelThenRestore marks a backup Job that is the safety snapshot in front of
@@ -90,14 +91,18 @@ const (
KindReap = "reap" KindReap = "reap"
) )
// FilesModeWrite is the LabelFilesMode value of a file write. // The LabelFilesMode values of the two file operations that only look: they
const FilesModeWrite = "write" // mount the world read-only, so they hold nothing.
const (
FilesModeList = "list"
FilesModeRead = "read"
)
// JobKind names the holder a Job represents, or reports false for a Job that // JobKind names the holder a Job represents, or reports false for a Job that
// holds nothing (a file read, a build, anything else in the namespace). A files // holds nothing (a file read, a build, anything else in the namespace). A files
// Job without LabelFilesMode predates the label and is counted as a write: it can // Job holds unless it is a list or a read, so an operation this build does not
// only be an old Job still inside its TTL, and over-counting it for that window // know — and a Job without LabelFilesMode, which can only be an old one still
// is the safe side. // inside its TTL — counts as a change: over-counting is the safe side.
func JobKind(j *batchv1.Job) (string, bool) { func JobKind(j *batchv1.Job) (string, bool) {
switch j.Labels[LabelManagedBy] { switch j.Labels[LabelManagedBy] {
case "felis-restore": case "felis-restore":
@@ -105,8 +110,7 @@ func JobKind(j *batchv1.Job) (string, bool) {
case "felis-backup": case "felis-backup":
return KindBackup, true return KindBackup, true
case "felis-files": case "felis-files":
mode, ok := j.Labels[LabelFilesMode] if mode := j.Labels[LabelFilesMode]; mode != FilesModeList && mode != FilesModeRead {
if !ok || mode == FilesModeWrite {
return KindFileWrite, true return KindFileWrite, true
} }
} }
+15 -1
View File
@@ -51,8 +51,13 @@ func filesJob(t *testing.T, server, op string) batchv1.Job {
Image: "felis:1", WorldsRoot: "/data", Deadline: time.Minute, CPULimit: "500m", Image: "felis:1", WorldsRoot: "/data", Deadline: time.Minute, CPULimit: "500m",
MemLimit: "256Mi", TTLAfterFinished: time.Minute, MemLimit: "256Mi", TTLAfterFinished: time.Minute,
} }
if op == fileedit.OpWrite { switch op {
case fileedit.OpWrite:
p.Content = []byte("motd=hi\n") p.Content = []byte("motd=hi\n")
case fileedit.OpRename:
p.To = "server.properties.bak"
case fileedit.OpUpload:
p.SourceURL, p.UploadToken = "http://felis-api-internal.felis.svc.cluster.local:8081/x", "t"
} }
j, err := fileedit.FilesJob(p) j, err := fileedit.FilesJob(p)
if err != nil { if err != nil {
@@ -78,6 +83,10 @@ func TestJobKindMatchesTheExecutors(t *testing.T) {
{"restore", restoreJob(t, "survival"), KindRestore, true}, {"restore", restoreJob(t, "survival"), KindRestore, true},
{"backup", backupJob(t, "survival"), KindBackup, true}, {"backup", backupJob(t, "survival"), KindBackup, true},
{"file write", filesJob(t, "survival", fileedit.OpWrite), KindFileWrite, true}, {"file write", filesJob(t, "survival", fileedit.OpWrite), KindFileWrite, true},
{"file mkdir", filesJob(t, "survival", fileedit.OpMkdir), KindFileWrite, true},
{"file delete", filesJob(t, "survival", fileedit.OpDelete), KindFileWrite, true},
{"file rename", filesJob(t, "survival", fileedit.OpRename), KindFileWrite, true},
{"file upload", filesJob(t, "survival", fileedit.OpUpload), KindFileWrite, true},
{"file read", filesJob(t, "survival", fileedit.OpRead), "", false}, {"file read", filesJob(t, "survival", fileedit.OpRead), "", false},
{"file list", filesJob(t, "survival", fileedit.OpList), "", false}, {"file list", filesJob(t, "survival", fileedit.OpList), "", false},
} { } {
@@ -97,6 +106,11 @@ func TestUnlabelledFilesJobCountsAsWrite(t *testing.T) {
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite { if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job from before the mode label = %q, %v; want file-write", kind, ok) t.Fatalf("a files Job from before the mode label = %q, %v; want file-write", kind, ok)
} }
// An operation a later build adds holds too, until this build learns it.
j.Labels[LabelFilesMode] = "chmod"
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job with an unknown mode = %q, %v; want file-write", kind, ok)
}
} }
func TestHolderFromJobs(t *testing.T) { func TestHolderFromJobs(t *testing.T) {
+225
View File
@@ -89,6 +89,8 @@ interface MockState {
passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>; passkeys: Record<AccountID, { id: string; name: string; created_at: string }[]>;
submissions: Submission[]; submissions: Submission[];
updateWindow: { start: string | null; end: string | null }; updateWindow: { start: string | null; end: string | null };
// Each server's world volume, seeded on first visit.
files: Record<string, MockTree>;
} }
// PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock // PLAYER_NAME mirrors the backend's mcNameRe (handlers_access.go) so the mock
@@ -446,6 +448,7 @@ function initialState(): MockState {
}, },
], ],
updateWindow: { start: null, end: null }, updateWindow: { start: null, end: null },
files: {},
}; };
} }
@@ -2307,10 +2310,232 @@ async function handleServerRoute(ctx: SessionContext): Promise<boolean> {
if (ctx.parts[4] === "access") { if (ctx.parts[4] === "access") {
return handleAccessMock(ctx, serverInfo); return handleAccessMock(ctx, serverInfo);
} }
if (ctx.parts[4] === "files" || ctx.parts[4] === "file") {
return handleFilesMock(ctx, serverInfo);
}
return false; return false;
} }
// ---- server files (handlers_files.go) ----
interface MockFileNode {
is_dir: boolean;
data: Buffer;
mod_time: string;
}
type MockTree = Map<string, MockFileNode>;
const MOCK_MAX_WRITE = 256 * 1024;
const MOCK_MAX_READ = 1024 * 1024;
const MOCK_MAX_UPLOAD = 64 * 1024 * 1024;
const MOCK_SECRET_CONFIG = "config/paper-global.yml";
const MOCK_MANAGED = new Set([MOCK_SECRET_CONFIG, "config", "server.properties"]);
// seedFiles is a Paper server's volume after a few days of play: the configs an
// owner edits, plugin jars, a world and its logs. Sizes are real-looking, the
// binary content is zeros.
function seedFiles(serverName: string): MockTree {
const tree: MockTree = new Map();
const ago = (hours: number) => new Date(Date.now() - hours * 3_600_000).toISOString();
const dir = (p: string, hours: number) => tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: ago(hours) });
const file = (p: string, content: string | number, hours: number) =>
tree.set(p, {
is_dir: false,
data: typeof content === "string" ? Buffer.from(content) : Buffer.alloc(content),
mod_time: ago(hours),
});
dir("", 0);
file(
"server.properties",
`#Minecraft server properties\nmotd=${serverName}\nmax-players=20\nonline-mode=false\ndifficulty=normal\ngamemode=survival\npvp=true\nview-distance=10\nspawn-protection=16\nenable-rcon=true\nrcon.password=[redacted by felis]\n`,
30,
);
file("eula.txt", "eula=true\n", 400);
file("ops.json", "[]\n", 50);
file("whitelist.json", '[\n {\n "uuid": "069a79f4-44e9-4726-a5be-fca90e38aaf5",\n "name": "Notch"\n }\n]\n', 50);
file("banned-players.json", "[]\n", 50);
file("bukkit.yml", "settings:\n allow-end: true\n warn-on-overload: true\nspawn-limits:\n monsters: 70\n", 300);
file("spigot.yml", "settings:\n debug: false\n restart-on-crash: true\n", 300);
dir("config", 300);
file(MOCK_SECRET_CONFIG, "# Do not hand-edit: felis rewrites this file on every boot.\n", 30);
file("config/paper-world-defaults.yml", "entities:\n spawning:\n per-player-mob-spawns: true\n", 300);
dir("plugins", 72);
file("plugins/LuckPerms-Bukkit-5.4.141.jar", 1_380_214, 72);
file("plugins/EssentialsX-2.20.1.jar", 3_120_876, 70);
file("plugins/Chunky-Bukkit-1.4.28.jar", 412_553, 12);
dir("plugins/LuckPerms", 72);
file("plugins/LuckPerms/config.yml", "server: global\nstorage-method: h2\n", 72);
dir("plugins/Essentials", 70);
file("plugins/Essentials/config.yml", "ops-name-color: '4'\nnickname-prefix: '~'\n", 70);
dir("world", 2);
file("world/level.dat", 2_431, 2);
file("world/session.lock", 3, 2);
dir("world/region", 2);
file("world/region/r.0.0.mca", 8_392_704, 2);
file("world/region/r.-1.0.mca", 5_246_976, 3);
file("world/region/r.0.-1.mca", 6_295_552, 26);
dir("world_nether", 26);
dir("world_the_end", 140);
dir("logs", 2);
file("logs/latest.log", `[12:00:00] [Server thread/INFO]: Starting minecraft server version 1.21.1\n[12:00:04] [Server thread/INFO]: Done (3.912s)! For help, type "help"\n`, 2);
return tree;
}
function filesOf(state: MockState, serverName: string): MockTree {
let tree = state.files[serverName];
if (!tree) {
tree = seedFiles(serverName);
state.files[serverName] = tree;
}
return tree;
}
function parentPath(p: string): string {
const i = p.lastIndexOf("/");
return i === -1 ? "" : p.slice(0, i);
}
// cleanFilePath is os.Root's containment in miniature: "." segments and empty
// ones collapse, and ".." or an absolute path is 400 bad_path. null means the
// response has been sent.
function cleanFilePath(ctx: SessionContext, raw: string | null, allowRoot = false): string | null {
if (raw === null || (raw === "" && !allowRoot)) {
sendError(ctx.res, 400, "bad_request", "path is required");
return null;
}
if (raw.startsWith("/") || raw.split("/").includes("..")) {
sendError(ctx.res, 400, "bad_path", `${raw} escapes the world root`);
return null;
}
const p = raw.split("/").filter((s) => s !== "" && s !== ".").join("/");
if (p === "" && !allowRoot) {
sendError(ctx.res, 400, "bad_path", "the world root itself cannot be changed");
return null;
}
return p;
}
function mockSha(data: Buffer): string {
return createHash("sha256").update(data).digest("hex");
}
async function readRawBody(req: IncomingMessage): Promise<Buffer> {
const chunks: Buffer[] = [];
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
return Buffer.concat(chunks);
}
// handleFilesMock mirrors the file routes: owner-or-admin, the stopped gate, then
// the op. Each real call is a Job that takes seconds; the pause stands in for it.
async function handleFilesMock(ctx: SessionContext, serverInfo: MockServer): Promise<boolean> {
const route = ctx.parts.slice(4).join("/");
const known = ["GET files", "GET file", "PUT file", "DELETE file", "POST files/mkdir", "POST files/rename", "PUT files/upload"];
const key = `${ctx.method} ${route}`;
const fail = (status: number, code: string, message: string): true => {
sendError(ctx.res, status, code, message);
return true;
};
if (!known.includes(key)) return false;
if (!canManage(ctx.account, serverInfo)) {
sendError(ctx.res, 403, "forbidden", "server is not owned by this account");
return true;
}
if (serverInfo.phase !== "Stopped") {
sendError(ctx.res, 409, "not_stopped", "stop the server before editing its files");
return true;
}
const url = new URL(ctx.req.url ?? "/", "http://localhost");
const tree = filesOf(ctx.state, serverInfo.name);
const p = cleanFilePath(ctx, url.searchParams.get("path"), key === "GET files");
if (p === null) return true;
await new Promise((r) => setTimeout(r, 350));
const now = new Date().toISOString();
const node = tree.get(p);
const parent = tree.get(parentPath(p));
switch (key) {
case "GET files": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (!node.is_dir) return fail(400, "bad_path", `${p} is not a directory`);
const entries = [...tree.entries()]
.filter(([k]) => k !== "" && parentPath(k) === p)
.map(([k, n]) => ({ name: k.slice(p === "" ? 0 : p.length + 1), size: n.is_dir ? 4096 : n.data.length, is_dir: n.is_dir, mod_time: n.mod_time }))
.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
sendJSON(ctx.res, 200, { path: p, entries, truncated: false });
return true;
}
case "GET file": {
if (p === MOCK_SECRET_CONFIG) return fail(400, "bad_path", `${p} holds the proxy forwarding secret`);
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (node.is_dir) return fail(400, "bad_path", `${p} is a directory, not a file`);
if (node.data.length > MOCK_MAX_READ) return fail(413, "too_large", `${p} is larger than the editor reads`);
sendJSON(ctx.res, 200, { path: p, content: node.data.toString("base64"), sha256: mockSha(node.data) });
return true;
}
case "PUT file": {
const body = await readJSON<{ content?: string; expect_sha256?: string; create_only?: boolean }>(ctx.req);
if (typeof body.content !== "string") return fail(400, "bad_request", "content is required");
const data = Buffer.from(body.content, "base64");
if (data.length > MOCK_MAX_WRITE) return fail(413, "too_large", "the content is larger than the editor writes");
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (body.create_only && node) return fail(409, "file_exists", `${p} already exists`);
if (body.expect_sha256 && (!node || mockSha(node.data) !== body.expect_sha256)) {
return fail(409, "file_changed", `${p} changed since it was read`);
}
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "written", sha256: mockSha(data) });
return true;
}
case "DELETE file": {
if (!node) return fail(404, "not_found", `${p} does not exist`);
for (const k of [...tree.keys()]) if (k === p || k.startsWith(`${p}/`)) tree.delete(k);
sendJSON(ctx.res, 200, { path: p, status: "deleted" });
return true;
}
case "POST files/mkdir": {
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node) return fail(409, "file_exists", `${p} already exists`);
tree.set(p, { is_dir: true, data: Buffer.alloc(0), mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "created" });
return true;
}
case "POST files/rename": {
const body = await readJSON<{ to?: string }>(ctx.req);
const to = cleanFilePath(ctx, body.to ?? "");
if (to === null) return true;
if (!node) return fail(404, "not_found", `${p} does not exist`);
if (MOCK_MANAGED.has(p)) return fail(400, "bad_path", `${p} is managed by felis and cannot be moved or renamed`);
if (tree.has(to)) return fail(409, "file_exists", `${to} already exists`);
if (!tree.get(parentPath(to))?.is_dir) return fail(404, "not_found", `folder ${parentPath(to)} does not exist`);
for (const [k, n] of [...tree.entries()]) {
if (k === p || k.startsWith(`${p}/`)) {
tree.delete(k);
tree.set(to + k.slice(p.length), n);
}
}
sendJSON(ctx.res, 200, { path: p, to, status: "renamed" });
return true;
}
case "PUT files/upload": {
const declared = Number(ctx.req.headers["content-length"] ?? NaN);
if (Number.isNaN(declared)) return fail(411, "length_required", "an upload needs a Content-Length");
if (declared > MOCK_MAX_UPLOAD) return fail(413, "too_large", "uploads are at most 64 MiB");
const data = await readRawBody(ctx.req);
if (!parent?.is_dir) return fail(404, "not_found", `folder ${parentPath(p)} does not exist`);
if (node?.is_dir) return fail(400, "bad_path", `${p} is a directory`);
if (node && url.searchParams.get("overwrite") !== "true") return fail(409, "file_exists", `${p} already exists`);
// Landing takes the Job a moment after the body is in.
await new Promise((r) => setTimeout(r, 900));
tree.set(p, { is_dir: false, data, mod_time: now });
sendJSON(ctx.res, 200, { path: p, status: "uploaded", sha256: mockSha(data), size: data.length });
return true;
}
}
return false;
}
// handleRestoreBackupMock mirrors the backend's restore authorization order // handleRestoreBackupMock mirrors the backend's restore authorization order
// (handlers_backups.go): owner-or-admin → specific backup by id or latest present // (handlers_backups.go): owner-or-admin → specific backup by id or latest present
// backup else 404 no_backup → non-admin former-owner match → stopped gate else // backup else 404 no_backup → non-admin former-owner match → stopped gate else
+137
View File
@@ -0,0 +1,137 @@
import { useRef, useState, type FormEvent } from "react";
import { useTranslation } from "react-i18next";
import { Loader2 } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Label } from "@/components/ui/label";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { MessageLine } from "@/components/MessageLine";
import { humanizeError } from "@/lib/api";
interface Props {
open: boolean;
onOpenChange: (open: boolean) => void;
title: string;
description?: string;
label: string;
confirmLabel: string;
/** What the field starts with. A rename starts from the old name, selected up
* to its extension so typing replaces just the name. */
initial?: string;
/** Why a name cannot be used, as a sentence, or null when it can. */
problem: (name: string) => string | null;
/** Runs the action with the name less surrounding spaces. The dialog closes
* when it resolves; a rejection is shown in the dialog, which stays open. */
onSubmit: (name: string) => Promise<void>;
}
// NameDialog asks for one file or folder name. The problem with a name shows as
// soon as something has been typed, so the button is never disabled without a
// reason on screen; the server's own refusal lands in the same place. Mount it
// afresh for each question (a key per opening): the field starts from `initial`.
export function NameDialog({
open,
onOpenChange,
title,
description,
label,
confirmLabel,
initial = "",
problem,
onSubmit,
}: Props) {
const { t } = useTranslation("common");
const [value, setValue] = useState(initial);
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const inputRef = useRef<HTMLInputElement>(null);
const issue = problem(value);
const unchanged = initial !== "" && value.trim() === initial;
// An untouched empty field has nothing wrong with it yet; saying so before the
// first keystroke would greet the dialog with an error.
const shownIssue = value === "" && initial === "" ? null : issue;
function setOpen(next: boolean) {
if (busy) return;
onOpenChange(next);
}
// The submit button is disabled whenever the name cannot be sent, and a form
// whose default button is disabled does not submit on Enter either.
async function submit(e: FormEvent) {
e.preventDefault();
setBusy(true);
setError(null);
try {
await onSubmit(value.trim());
setBusy(false);
onOpenChange(false);
} catch (err) {
setError(humanizeError(err));
setBusy(false);
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogContent
onOpenAutoFocus={(e) => {
e.preventDefault();
const input = inputRef.current;
if (!input) return;
input.focus();
const dot = initial.lastIndexOf(".");
input.setSelectionRange(0, dot > 0 ? dot : initial.length);
}}
>
<form onSubmit={(e) => void submit(e)} className="grid gap-4">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
{description && <DialogDescription>{description}</DialogDescription>}
</DialogHeader>
<div className="grid gap-2">
<Label htmlFor="file-name">{label}</Label>
<Input
id="file-name"
ref={inputRef}
value={value}
onChange={(e) => {
setValue(e.target.value);
setError(null);
}}
disabled={busy}
autoComplete="off"
spellCheck={false}
aria-invalid={shownIssue !== null}
aria-describedby={shownIssue ? "file-name-problem" : undefined}
className="font-mono"
/>
{shownIssue && (
<p id="file-name-problem" className="text-xs text-destructive">
{shownIssue}
</p>
)}
</div>
{error && <MessageLine kind="error" message={error} compact />}
<DialogFooter>
<Button type="button" variant="outline" size="sm" onClick={() => setOpen(false)} disabled={busy}>
{t("cancel")}
</Button>
<Button type="submit" size="sm" disabled={busy || issue !== null || unchanged}>
{busy && <Loader2 className="h-4 w-4 animate-spin" />}
{confirmLabel}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}
+185
View File
@@ -0,0 +1,185 @@
import { useTranslation } from "react-i18next";
import { AlertCircle, AlertTriangle, CheckCircle2, Clock, Loader2, RotateCw, Upload, X } from "lucide-react";
import { Button } from "@/components/ui/button";
import { formatBytes } from "@/lib/format";
import { cn } from "@/lib/utils";
import type { UploadItem } from "./useUploads";
interface Props {
items: readonly UploadItem[];
/** The folder on screen: an upload headed elsewhere names its folder. */
dir: string;
onReplace: (id: number) => void;
onRetry: (id: number) => void;
onRemove: (id: number) => void;
onReplaceAll: () => void;
onSkipAll: () => void;
onClearDone: () => void;
}
// UploadQueue shows each upload and what it waits on. A file already there asks
// replace or skip on its own row, so a batch never overwrites anything unasked.
export function UploadQueue({ items, dir, onReplace, onRetry, onRemove, onReplaceAll, onSkipAll, onClearDone }: Props) {
const { t } = useTranslation("files");
if (items.length === 0) return null;
const done = items.filter((it) => it.state === "done").length;
const asking = items.filter((it) => it.state === "exists").length;
return (
<section aria-label={t("uploads_label")} className="border-b border-border bg-muted/20">
<div className="flex flex-wrap items-center gap-2 px-4 pt-3 pb-2">
<Upload className="h-4 w-4 text-muted-foreground" />
<p className="text-sm font-medium">{t("uploads_title", { done, total: items.length })}</p>
<div className="ml-auto flex flex-wrap items-center gap-1">
{asking > 1 && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onReplaceAll}>
{t("upload_replace_all")}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onSkipAll}>
{t("upload_skip_all")}
</Button>
</>
)}
{done > 0 && (
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onClearDone}>
{t("upload_clear_done")}
</Button>
)}
</div>
</div>
<ul className="max-h-72 divide-y divide-border/60 overflow-y-auto px-4 pb-2">
{items.map((it) => (
<UploadRow
key={it.id}
item={it}
elsewhere={it.dir !== dir}
onReplace={() => onReplace(it.id)}
onRetry={() => onRetry(it.id)}
onRemove={() => onRemove(it.id)}
/>
))}
</ul>
</section>
);
}
function UploadRow({
item,
elsewhere,
onReplace,
onRetry,
onRemove,
}: {
item: UploadItem;
elsewhere: boolean;
onReplace: () => void;
onRetry: () => void;
onRemove: () => void;
}) {
const { t } = useTranslation("files");
const { file, state } = item;
const percent = file.size > 0 ? Math.min(100, Math.floor((item.sent * 100) / file.size)) : 100;
// The body is all sent and the Job is landing it: seconds more, with no bytes
// left to count.
const landing = state === "uploading" && item.sent >= file.size;
const icon = {
queued: <Clock className="h-4 w-4 text-muted-foreground" />,
uploading: <Loader2 className="h-4 w-4 animate-spin text-primary" />,
done: <CheckCircle2 className="h-4 w-4 text-emerald-500" />,
exists: <AlertTriangle className="h-4 w-4 text-amber-500" />,
failed: <AlertCircle className="h-4 w-4 text-destructive" />,
}[state];
let status: string;
if (state === "queued") status = t("upload_queued");
else if (landing) status = t("upload_landing");
else if (state === "uploading") status = t("upload_sending", { sent: formatBytes(item.sent), total: formatBytes(file.size), percent });
else if (state === "done") status = t("upload_done");
else if (state === "exists") status = t("upload_exists");
else status = item.error ?? "";
return (
<li className="py-2">
<div className="flex items-start gap-2.5">
<span className="mt-0.5 shrink-0">{icon}</span>
<div className="min-w-0 flex-1">
<div className="flex min-w-0 items-baseline gap-2">
<span className="truncate font-mono text-xs" title={file.name}>
{file.name}
</span>
<span className="shrink-0 text-[11px] text-muted-foreground tabular-nums">{formatBytes(file.size)}</span>
</div>
{elsewhere && (
<p className="truncate font-mono text-[11px] text-muted-foreground">
→ {item.dir === "" ? t("root") : `${item.dir}/`}
</p>
)}
<p
className={cn(
"mt-0.5 text-xs tabular-nums",
state === "failed"
? "text-destructive"
: state === "exists"
? "text-amber-600 dark:text-amber-400"
: "text-muted-foreground",
)}
>
{status}
</p>
{state === "uploading" && (
<div
role="progressbar"
aria-label={t("upload_progress_label", { name: file.name })}
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={percent}
className="mt-1.5 h-1 w-full overflow-hidden rounded-full bg-muted"
>
<div
className={cn(
"h-full rounded-full bg-primary transition-[width] duration-300 ease-out",
landing && "animate-pulse",
)}
style={{ width: `${percent}%` }}
/>
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-1">
{state === "exists" && (
<>
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onReplace}>
{t("upload_replace")}
</Button>
<Button size="sm" variant="ghost" className="h-7 text-xs" onClick={onRemove}>
{t("upload_skip")}
</Button>
</>
)}
{state === "failed" && item.retryable && (
<Button size="sm" variant="outline" className="h-7 text-xs" onClick={onRetry}>
<RotateCw className="h-3.5 w-3.5" />
{t("upload_retry")}
</Button>
)}
{/* Once the body is all sent the Job may already be landing it, and
a cancel could no longer say whether the file changed. */}
{state !== "exists" && state !== "done" && !landing && (
<Button
size="sm"
variant="ghost"
className="h-7 w-7 p-0"
onClick={onRemove}
aria-label={t(state === "failed" ? "upload_dismiss" : "upload_cancel", { name: file.name })}
title={t(state === "failed" ? "upload_dismiss" : "upload_cancel", { name: file.name })}
>
<X className="h-3.5 w-3.5" />
</Button>
)}
</div>
</div>
</li>
);
}
+96
View File
@@ -0,0 +1,96 @@
import { describe, it, expect } from "vitest";
import { isManaged, joinPath, nameProblem, parentOf, sortEntries } from "./names";
import type { ServerFileEntry } from "@/lib/types";
const entry = (name: string, is_dir = false): ServerFileEntry => ({ name, size: 1, is_dir, mod_time: "2026-09-01T00:00:00Z" });
describe("joinPath and parentOf", () => {
it("join at the root without a leading slash, and walk back to it", () => {
expect(joinPath("", "world")).toBe("world");
expect(joinPath("world/region", "r.0.0.mca")).toBe("world/region/r.0.0.mca");
expect(parentOf("world/region")).toBe("world");
expect(parentOf("world")).toBe("");
});
});
describe("isManaged", () => {
it("names exactly the paths the read path guards by name", () => {
expect(isManaged("server.properties")).toBe(true);
expect(isManaged("config")).toBe(true);
expect(isManaged("config/paper-global.yml")).toBe(true);
// The same names anywhere else are the owner's own.
expect(isManaged("backup/server.properties")).toBe(false);
expect(isManaged("config/paper-world-defaults.yml")).toBe(false);
expect(isManaged("plugins/config")).toBe(false);
});
});
describe("nameProblem", () => {
const here = [entry("world", true), entry("eula.txt")];
it("takes a free name, less the spaces around it", () => {
expect(nameProblem(" notes.txt ", here)).toBeNull();
});
it("asks for a name when there is none but spaces", () => {
expect(nameProblem("", here)).toBe("name_required");
expect(nameProblem(" ", here)).toBe("name_required");
});
it("refuses a slash, which would reach into another folder", () => {
expect(nameProblem("config/x", here)).toBe("name_slash");
});
it("refuses the names that mean this folder or its parent", () => {
expect(nameProblem(".", here)).toBe("name_dots");
expect(nameProblem(" .. ", here)).toBe("name_dots");
expect(nameProblem("...", here)).toBeNull();
expect(nameProblem(".hidden", here)).toBeNull();
});
it("counts the length in bytes, as the filesystem does", () => {
expect(nameProblem("a".repeat(255), here)).toBeNull();
expect(nameProblem("a".repeat(256), here)).toBe("name_too_long");
// 85 three-byte characters are 255 bytes; one more is over.
expect(nameProblem("界".repeat(85), here)).toBeNull();
expect(nameProblem("界".repeat(86), here)).toBe("name_too_long");
});
it("refuses a name already in the folder, file or folder alike", () => {
expect(nameProblem("eula.txt", here)).toBe("name_taken");
expect(nameProblem(" world", here)).toBe("name_taken");
expect(nameProblem("World", here)).toBeNull();
});
it("lets a rename keep its own name, and no other one there", () => {
expect(nameProblem("world", here, "world")).toBeNull();
expect(nameProblem("eula.txt", here, "world")).toBe("name_taken");
});
it("checks nothing against a folder not listed yet", () => {
expect(nameProblem("eula.txt", null)).toBeNull();
});
});
describe("sortEntries", () => {
it("puts folders first, then orders names by their numbers and without case", () => {
const listed = [
entry("r.10.0.mca"),
entry("world", true),
entry("Banned-players.json"),
entry("r.2.0.mca"),
entry("logs", true),
entry("apple.txt"),
];
expect(sortEntries(listed).map((e) => e.name)).toEqual([
"logs",
"world",
"apple.txt",
"Banned-players.json",
"r.2.0.mca",
"r.10.0.mca",
]);
// The listing it was given stays as the server sent it.
expect(listed[0].name).toBe("r.10.0.mca");
});
});
+57
View File
@@ -0,0 +1,57 @@
import type { ServerFileEntry } from "@/lib/types";
export function joinPath(dir: string, name: string): string {
return dir === "" ? name : `${dir}/${name}`;
}
export function parentOf(dir: string): string {
const i = dir.lastIndexOf("/");
return i === -1 ? "" : dir.slice(0, i);
}
/** The file whose platform secret the read path refuses outright
* (fileedit.secretConfigPath). */
export const SECRET_CONFIG_PATH = "config/paper-global.yml";
/** The paths the read path guards by name (fileedit.guardedPaths): under another
* name it would hand back what it withholds, so the server refuses to move them
* (400 bad_path) and the page does not offer to. */
const MANAGED_PATHS = new Set([SECRET_CONFIG_PATH, "config", "server.properties"]);
export function isManaged(path: string): boolean {
return MANAGED_PATHS.has(path);
}
/** The longest name a Linux filesystem takes, in bytes (NAME_MAX). */
const NAME_MAX = 255;
export type NameProblem = "name_required" | "name_slash" | "name_dots" | "name_too_long" | "name_taken";
/** nameProblem says why name cannot be used for a new entry in a folder listing
* `entries`, or null when it can. `current` is the entry being renamed, whose own
* name is not a clash. The name is taken as typed less surrounding spaces, which
* is what the page sends. */
export function nameProblem(
raw: string,
entries: readonly ServerFileEntry[] | null,
current?: string,
): NameProblem | null {
const name = raw.trim();
if (name === "") return "name_required";
if (name.includes("/")) return "name_slash";
if (name === "." || name === "..") return "name_dots";
if (new TextEncoder().encode(name).length > NAME_MAX) return "name_too_long";
if (name !== current && entries?.some((e) => e.name === name)) return "name_taken";
return null;
}
/** sortEntries puts folders first, then orders names the way people count
* ("r.2.0" before "r.10.0"), with case second to the letters ("apple" before
* "Banned"). The server lists in byte order. */
export function sortEntries(entries: readonly ServerFileEntry[]): ServerFileEntry[] {
return [...entries].sort(
(a, b) =>
Number(b.is_dir) - Number(a.is_dir) ||
a.name.localeCompare(b.name, undefined, { numeric: true }),
);
}
+124
View File
@@ -0,0 +1,124 @@
import { useCallback, useEffect, useRef, useState } from "react";
import i18next from "i18next";
import { api, humanizeError } from "@/lib/api";
import { formatBytes } from "@/lib/format";
import type { ServerFileEntry } from "@/lib/types";
import { joinPath } from "./names";
/** The upload ceiling, mirrored from fileedit.MaxUploadBytes (server truth, 413
* above it). Checked here so a file too large is refused before it is sent. */
export const MAX_UPLOAD_BYTES = 64 * 1024 * 1024;
/** queued waits its turn; exists found a file already at its path and waits for
* replace or skip; failed stays until it is retried or dismissed. */
export type UploadState = "queued" | "uploading" | "done" | "exists" | "failed";
export interface UploadItem {
id: number;
file: File;
/** The folder it lands in, fixed when it was added. */
dir: string;
state: UploadState;
sent: number;
overwrite: boolean;
error: string | null;
/** false for a refusal sending again cannot change (too large, a folder there). */
retryable: boolean;
}
// useUploads runs a queue of uploads into a server's world volume, one at a time:
// each is a file Job that holds the world lock, so a second one sent alongside
// would only be refused with 409 maintenance_in_progress. onLanded runs after
// each upload that landed, with the folder it landed in.
export function useUploads(server: string, onLanded: (dir: string) => void) {
const [items, setItems] = useState<UploadItem[]>([]);
const nextId = useRef(1);
const running = useRef<number | null>(null);
const abort = useRef<AbortController | null>(null);
const landed = useRef(onLanded);
landed.current = onLanded;
const patch = useCallback((id: number, change: Partial<UploadItem>) => {
setItems((all) => all.map((it) => (it.id === id ? { ...it, ...change } : it)));
}, []);
const drop = useCallback((id: number) => {
setItems((all) => all.filter((it) => it.id !== id));
}, []);
// Leaving the page stops the upload in flight; the queued ones were never sent.
useEffect(() => () => abort.current?.abort(), []);
useEffect(() => {
if (running.current !== null) return;
const next = items.find((it) => it.state === "queued");
if (!next) return;
running.current = next.id;
const ctrl = new AbortController();
abort.current = ctrl;
patch(next.id, { state: "uploading", sent: 0, error: null });
const settle = () => {
running.current = null;
abort.current = null;
};
api
.uploadServerFile(server, joinPath(next.dir, next.file.name), next.file, next.overwrite, {
signal: ctrl.signal,
onProgress: (sent) => patch(next.id, { sent }),
})
.then(
() => {
settle();
patch(next.id, { state: "done", sent: next.file.size });
landed.current(next.dir);
},
(e: unknown) => {
settle();
if (e instanceof DOMException && e.name === "AbortError") {
drop(next.id);
} else if ((e as { code?: string }).code === "file_exists") {
// Someone put a file there since the listing: ask, as for one listed.
patch(next.id, { state: "exists", sent: 0 });
} else {
patch(next.id, { state: "failed", sent: 0, error: humanizeError(e), retryable: true });
}
},
);
}, [items, server, patch, drop]);
/** add queues files for dir. `entries` is dir's listing: a file of the same
* name there waits for replace or skip instead of being sent to be refused. */
const add = useCallback((files: readonly File[], dir: string, entries: readonly ServerFileEntry[] | null) => {
const t = i18next.getFixedT(null, "files");
const added = files.map((file): UploadItem => {
const base = { id: nextId.current++, file, dir, sent: 0, overwrite: false, error: null, retryable: false };
const there = entries?.find((e) => e.name === file.name);
if (file.size > MAX_UPLOAD_BYTES) {
return { ...base, state: "failed", error: t("upload_too_large", { limit: formatBytes(MAX_UPLOAD_BYTES) }) };
}
if (there?.is_dir) {
return { ...base, state: "failed", error: t("upload_folder_there") };
}
return { ...base, state: there ? "exists" : "queued" };
});
setItems((all) => [...all, ...added]);
}, []);
const replace = useCallback((id: number) => patch(id, { state: "queued", overwrite: true }), [patch]);
const retry = useCallback((id: number) => patch(id, { state: "queued", error: null }), [patch]);
/** remove cancels an upload in flight, or takes any other one off the list. */
const remove = useCallback(
(id: number) => {
if (running.current === id) abort.current?.abort();
else drop(id);
},
[drop],
);
const replaceAll = useCallback(() => {
setItems((all) => all.map((it) => (it.state === "exists" ? { ...it, state: "queued", overwrite: true } : it)));
}, []);
const skipAll = useCallback(() => setItems((all) => all.filter((it) => it.state !== "exists")), []);
const clearDone = useCallback(() => setItems((all) => all.filter((it) => it.state !== "done")), []);
const busy = items.some((it) => it.state === "queued" || it.state === "uploading");
return { items, busy, add, replace, retry, remove, replaceAll, skipAll, clearDone };
}
+6 -2
View File
@@ -24,11 +24,11 @@
"luckperms_missing": "LuckPerms isn't installed on this server, so permission and group changes have no effect. For the lobby, ask the operator to re-run the installer; any other server needs the LuckPerms plugin added first.", "luckperms_missing": "LuckPerms isn't installed on this server, so permission and group changes have no effect. For the lobby, ask the operator to re-run the installer; any other server needs the LuckPerms plugin added first.",
"no_backup": "There's no restorable backup for this server yet.", "no_backup": "There's no restorable backup for this server yet.",
"backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.", "backup_corrupt": "This backup failed its read-back check and can't be restored intact — pick another backup.",
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.", "not_stopped": "Stop the server completely first — this changes its world volume, which the running server holds.",
"maintenance_in_progress": "This server's world is busy with a restore, backup, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; an idle reclaim can take longer on a large world.", "maintenance_in_progress": "This server's world is busy with a restore, backup, file write or idle reclaim — try again once it finishes. A restore, backup or file write usually takes a minute or two; an idle reclaim can take longer on a large world.",
"no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.", "no_world_volume": "This server has no world volume yet — start it once so it is created, then retry.",
"file_changed": "This file changed after you opened it (another manager saved it, or the server rewrote it on its last run), so your save was not written, to keep that change.", "file_changed": "This file changed after you opened it (another manager saved it, or the server rewrote it on its last run), so your save was not written, to keep that change.",
"volume_full": "The server's volume is full, so the save was not written and the file is unchanged. Ask an admin to grow or clean up this server's volume.", "volume_full": "The server's volume is full, so the change was not written and the files there are unchanged. Delete files it no longer needs, or ask an admin to grow its volume.",
"backup_cooldown": "This server was backed up moments ago, and manual backups have a cooldown — try again in a few minutes.", "backup_cooldown": "This server was backed up moments ago, and manual backups have a cooldown — try again in a few minutes.",
"backup_store_full": "The backup store is full, so manual backups are paused — ask an administrator to free space.", "backup_store_full": "The backup store is full, so manual backups are paused — ask an administrator to free space.",
"restore_unavailable": "Restore isn't available right now — try again later.", "restore_unavailable": "Restore isn't available right now — try again later.",
@@ -63,6 +63,10 @@
"too_large": "That is larger than the size limit.", "too_large": "That is larger than the size limit.",
"files_timeout": "The file operation timed out — try again shortly.", "files_timeout": "The file operation timed out — try again shortly.",
"files_unavailable": "File editing isn't available right now.", "files_unavailable": "File editing isn't available right now.",
"file_exists": "Something with that name is already there. Pick another name, or rename or delete the one that is there first.",
"upload_staging_full": "The panel's upload space is nearly full right now, so the file was not passed on. Try again later, or ask an admin to free space on the uploads volume.",
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.",
"length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.",
"jobs_unavailable": "The background job service isn't available right now.", "jobs_unavailable": "The background job service isn't available right now.",
"already_terminal": "This build already finished — there is nothing to cancel.", "already_terminal": "This build already finished — there is nothing to cancel.",
"build_unavailable": "Image builds aren't available right now.", "build_unavailable": "Image builds aren't available right now.",
+51 -2
View File
@@ -10,7 +10,7 @@
"col_size": "Size", "col_size": "Size",
"col_modified": "Modified", "col_modified": "Modified",
"empty_dir_title": "This folder is empty", "empty_dir_title": "This folder is empty",
"empty_dir_hint": "Nothing has been written here yet.", "empty_dir_hint": "Drop files here to upload them, or make a file or folder with the buttons above.",
"list_truncated": "The listing was truncated at the server's safety limit.", "list_truncated": "The listing was truncated at the server's safety limit.",
"stopped_required_title": "Stop the server first", "stopped_required_title": "Stop the server first",
"stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.", "stopped_required_body": "Files can only be browsed or edited while the server is fully stopped — the world volume is single-attached to the running server. Stop it to continue.",
@@ -30,5 +30,54 @@
"discard_prompt": "You have unsaved changes. Discard them?", "discard_prompt": "You have unsaved changes. Discard them?",
"discard": "Discard", "discard": "Discard",
"keep_editing": "Keep editing", "keep_editing": "Keep editing",
"status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}" "status_refresh_failed": "Could not refresh the server's status, so what you see may be out of date: {{reason}}",
"col_actions": "Actions",
"new_file": "New file",
"new_folder": "New folder",
"upload": "Upload",
"upload_hint": "Upload files into this folder (up to {{limit}} each), or drop them onto the list",
"in_folder": "In {{dir}}",
"name_label": "Name",
"create": "Create",
"rename": "Rename",
"rename_title": "Rename {{name}}",
"rename_item": "Rename {{name}}",
"delete_item": "Delete {{name}}",
"managed_no_rename": "Felis manages this one, so it keeps its name and place.",
"wait_for_uploads": "Wait for the uploads to finish first.",
"name_required": "Enter a name.",
"name_slash": "A name cannot contain “/”.",
"name_dots": "“.” and “..” cannot be used as names.",
"name_too_long": "That name is too long.",
"name_taken": "Something here already has that name.",
"folder_created": "Created folder {{path}}",
"renamed": "Renamed {{from}} to {{to}}",
"deleted": "Deleted {{path}}",
"delete": "Delete",
"delete_file_title": "Delete {{name}}?",
"delete_folder_title": "Delete the folder {{name}}?",
"delete_file_body": "The file is deleted for good. Take a backup first if you may want it back.",
"delete_folder_body": "The folder and everything in it are deleted for good. Take a backup first if you may want any of it back.",
"secret_config_unreadable": "config/paper-global.yml holds the proxy forwarding secret every server shares, so the editor does not open it.",
"drop_here": "Drop to upload into {{dir}}",
"upload_no_folders_one": "A folder cannot be uploaded, so it was skipped. Make the folder here, then upload the files inside it.",
"upload_no_folders_other": "Folders cannot be uploaded, so {{count}} were skipped. Make the folders here, then upload the files inside them.",
"uploads_label": "Uploads",
"uploads_title": "Uploads · {{done}} of {{total}} done",
"upload_queued": "Waiting",
"upload_sending": "{{sent}} of {{total}} · {{percent}}%",
"upload_landing": "Writing it to the server…",
"upload_done": "Uploaded",
"upload_exists": "A file with this name is already here.",
"upload_replace": "Replace",
"upload_skip": "Skip",
"upload_replace_all": "Replace all",
"upload_skip_all": "Skip all",
"upload_retry": "Retry",
"upload_cancel": "Cancel uploading {{name}}",
"upload_dismiss": "Dismiss {{name}}",
"upload_clear_done": "Clear finished",
"upload_progress_label": "Uploading {{name}}",
"upload_too_large": "Larger than the {{limit}} upload limit.",
"upload_folder_there": "A folder with this name is already here."
} }
+6 -2
View File
@@ -24,11 +24,11 @@
"luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。", "luckperms_missing": "这台服务器没有装 LuckPerms,权限和用户组的改动不会生效。大厅请让运维重跑安装脚本来补上;其他服务器需要先装 LuckPerms 插件。",
"no_backup": "这台服务器暂时没有可回档的备份。", "no_backup": "这台服务器暂时没有可回档的备份。",
"backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。", "backup_corrupt": "这份备份回读校验未通过,已无法完整恢复——请选择另一份备份。",
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。", "not_stopped": "请先把服务器完全停止——这项操作要改动世界存储卷,运行中的服务器独占着它。",
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。", "maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。", "no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。", "file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
"volume_full": "服务器的存储卷已满,这次保存没有写入,原文件保持不变。请联系管理员扩容或清理这台服务器的数据。", "volume_full": "服务器的存储卷已满,这次改动没有写入,原有文件保持不变。删掉用不着的文件,或者请管理员给它扩容。",
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。", "backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。", "backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
"restore_unavailable": "回档功能当前不可用,请稍后再试。", "restore_unavailable": "回档功能当前不可用,请稍后再试。",
@@ -63,6 +63,10 @@
"too_large": "内容超出大小限制,无法处理。", "too_large": "内容超出大小限制,无法处理。",
"files_timeout": "文件操作超时——请稍后重试。", "files_timeout": "文件操作超时——请稍后重试。",
"files_unavailable": "文件功能当前不可用。", "files_unavailable": "文件功能当前不可用。",
"file_exists": "这里已经有同名的文件或文件夹。换个名字,或者先把原来那个改名或删除。",
"upload_staging_full": "面板的上传暂存空间快满了,这个文件没有转存过去。稍后再试,或者请管理员清理上传卷。",
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
"jobs_unavailable": "后台任务服务当前不可用。", "jobs_unavailable": "后台任务服务当前不可用。",
"already_terminal": "该构建已经结束,无法重复取消。", "already_terminal": "该构建已经结束,无法重复取消。",
"build_unavailable": "构建功能当前不可用。", "build_unavailable": "构建功能当前不可用。",
+50 -2
View File
@@ -10,7 +10,7 @@
"col_size": "大小", "col_size": "大小",
"col_modified": "修改时间", "col_modified": "修改时间",
"empty_dir_title": "此目录为空", "empty_dir_title": "此目录为空",
"empty_dir_hint": "这里还没有写入任何内容。", "empty_dir_hint": "把文件拖到这里上传,或者用上方的按钮新建文件、文件夹。",
"list_truncated": "列表已按服务器安全上限截断。", "list_truncated": "列表已按服务器安全上限截断。",
"stopped_required_title": "需要先停服", "stopped_required_title": "需要先停服",
"stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。", "stopped_required_body": "只有在服务器完全停止后才能浏览或编辑文件——世界卷被运行中的服务器独占挂载。请先停止服务器。",
@@ -30,5 +30,53 @@
"discard_prompt": "有未保存的修改,要放弃吗?", "discard_prompt": "有未保存的修改,要放弃吗?",
"discard": "放弃修改", "discard": "放弃修改",
"keep_editing": "继续编辑", "keep_editing": "继续编辑",
"status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}" "status_refresh_failed": "刷新服务器状态失败,页面显示的可能不是最新状态:{{reason}}",
"col_actions": "操作",
"new_file": "新建文件",
"new_folder": "新建文件夹",
"upload": "上传",
"upload_hint": "上传文件到这个文件夹(每个最大 {{limit}}),也可以直接拖到列表上",
"in_folder": "位置:{{dir}}",
"name_label": "名称",
"create": "创建",
"rename": "重命名",
"rename_title": "重命名 {{name}}",
"rename_item": "重命名 {{name}}",
"delete_item": "删除 {{name}}",
"managed_no_rename": "这个由 Felis 管理,名称和位置都要保持不变。",
"wait_for_uploads": "等上传完成后再操作。",
"name_required": "请输入名称。",
"name_slash": "名称里不能有“/”。",
"name_dots": "“.” 和 “..” 不能用作名称。",
"name_too_long": "名称太长了。",
"name_taken": "这里已经有同名的文件或文件夹。",
"folder_created": "已新建文件夹 {{path}}",
"renamed": "已把 {{from}} 重命名为 {{to}}",
"deleted": "已删除 {{path}}",
"delete": "删除",
"delete_file_title": "删除 {{name}}?",
"delete_folder_title": "删除文件夹 {{name}}?",
"delete_file_body": "文件会被永久删除。之后可能还要用的话,先做一次备份。",
"delete_folder_body": "文件夹和里面的所有内容都会被永久删除。之后可能还要用的话,先做一次备份。",
"secret_config_unreadable": "config/paper-global.yml 里有所有服务器共用的代理转发密钥,编辑器不打开它。",
"drop_here": "松开即可上传到 {{dir}}",
"upload_no_folders": "文件夹没法直接上传,已跳过 {{count}} 个。先在这里新建同名文件夹,再上传里面的文件。",
"uploads_label": "上传",
"uploads_title": "上传 · 已完成 {{done}}/{{total}}",
"upload_queued": "等待中",
"upload_sending": "{{sent}} / {{total}} · {{percent}}%",
"upload_landing": "正在写入服务器…",
"upload_done": "已上传",
"upload_exists": "这里已经有同名文件。",
"upload_replace": "替换",
"upload_skip": "跳过",
"upload_replace_all": "全部替换",
"upload_skip_all": "全部跳过",
"upload_retry": "重试",
"upload_cancel": "取消上传 {{name}}",
"upload_dismiss": "移除 {{name}}",
"upload_clear_done": "清除已完成",
"upload_progress_label": "正在上传 {{name}}",
"upload_too_large": "超过 {{limit}} 的上传上限。",
"upload_folder_there": "这里已经有同名文件夹。"
} }
+118
View File
@@ -1331,6 +1331,115 @@ describe("chunked context upload", () => {
}); });
}); });
// The file manager's changes: each is one route, and what the server takes from
// it is the query path plus a small JSON body (an upload, the raw bytes).
describe("server file manager wire shapes", () => {
beforeEach(() => {
vi.restoreAllMocks();
FakeXHR.last = undefined;
vi.stubGlobal("XMLHttpRequest", FakeXHR);
});
afterEach(() => vi.unstubAllGlobals());
function sent(fetchSpy: typeof fetch): [string, RequestInit] {
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
return [String(url), opts as RequestInit];
}
it("createServerFile PUTs the content with create_only, so nothing already there is replaced", async () => {
const fetchSpy = fakeFetch({ path: "plugins/new.yml", status: "written", sha256: "c".repeat(64) });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.createServerFile("survival", "plugins/new.yml", "")).toEqual({
path: "plugins/new.yml",
status: "written",
sha256: "c".repeat(64),
});
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml");
expect(opts.method).toBe("PUT");
expect(opts.body).toBe(JSON.stringify({ content: "", create_only: true }));
});
it("deleteServerFile DELETEs /servers/{name}/file with no body", async () => {
const fetchSpy = fakeFetch({ path: "logs", status: "deleted" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.deleteServerFile("survival", "old logs")).toEqual({ path: "logs", status: "deleted" });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/file?path=old%20logs");
expect(opts.method).toBe("DELETE");
expect(opts.body).toBeUndefined();
});
it("mkdirServerFolder POSTs /servers/{name}/files/mkdir with the path in the query", async () => {
const fetchSpy = fakeFetch({ path: "plugins/Chunky", status: "created" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.mkdirServerFolder("survival", "plugins/Chunky")).toEqual({ path: "plugins/Chunky", status: "created" });
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/files/mkdir?path=plugins%2FChunky");
expect(opts.method).toBe("POST");
expect(opts.body).toBeUndefined();
});
it("renameServerFile POSTs the old path in the query and the new one as {to}", async () => {
const fetchSpy = fakeFetch({ path: "world", to: "world_old", status: "renamed" });
vi.stubGlobal("fetch", fetchSpy);
expect(await api.renameServerFile("survival", "world", "world old")).toEqual({
path: "world",
to: "world_old",
status: "renamed",
});
const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/files/rename?path=world");
expect(opts.method).toBe("POST");
expect(opts.body).toBe(JSON.stringify({ to: "world old" }));
});
it("uploadServerFile PUTs the raw bytes with the session cookie and reports progress", async () => {
const file = new Blob(["jar bytes"]);
const seen: number[] = [];
const done = api.uploadServerFile("survival", "plugins/Chunky 1.4.jar", file, false, {
onProgress: (n) => seen.push(n),
});
const xhr = await sentXHR();
expect(xhr.method).toBe("PUT");
expect(xhr.url).toBe("/servers/survival/files/upload?path=plugins%2FChunky%201.4.jar");
expect(xhr.withCredentials).toBe(true);
expect(xhr.headers).toEqual({ "Content-Type": "application/octet-stream" });
expect(xhr.body).toBe(file);
xhr.upload.onprogress?.({ loaded: 4 });
xhr.respond(200, JSON.stringify({ path: "plugins/Chunky 1.4.jar", status: "uploaded", sha256: "d".repeat(64), size: 9 }));
expect(await done).toEqual({ path: "plugins/Chunky 1.4.jar", status: "uploaded", sha256: "d".repeat(64), size: 9 });
expect(seen).toEqual([4]);
});
it("uploadServerFile asks to replace only when told to overwrite", async () => {
const done = api.uploadServerFile("survival", "server-icon.png", new Blob(["png"]), true);
const xhr = await sentXHR();
expect(xhr.url).toBe("/servers/survival/files/upload?path=server-icon.png&overwrite=true");
xhr.respond(200, JSON.stringify({ path: "server-icon.png", status: "uploaded", sha256: "e".repeat(64), size: 3 }));
await done;
});
it("an upload refused because the file is there reads as file_exists", async () => {
const done = api.uploadServerFile("survival", "server-icon.png", new Blob(["png"]), false);
(await sentXHR()).respond(
409,
JSON.stringify({ error: { code: "file_exists", message: "something is already at server-icon.png" } }),
"Conflict",
);
await expect(done).rejects.toEqual({ status: 409, code: "file_exists", message: "something is already at server-icon.png" });
});
it("refuses a server name that is not one path segment before sending anything", async () => {
const fetchSpy = fakeFetch({});
vi.stubGlobal("fetch", fetchSpy);
await expect(api.renameServerFile("..", "a", "b")).rejects.toMatchObject({ code: "bad_path_param" });
await expect(api.uploadServerFile(".", "x", new Blob(["x"]), false)).rejects.toMatchObject({ code: "bad_path_param" });
expect(fetchSpy).not.toHaveBeenCalled();
expect(FakeXHR.last).toBeUndefined();
});
});
// The API's generic codes carry an English developer message ("user not found", // The API's generic codes carry an English developer message ("user not found",
// "invalid request"); the panel words them itself so a Chinese UI never shows it. // "invalid request"); the panel words them itself so a Chinese UI never shows it.
describe("copy for the generic server codes", () => { describe("copy for the generic server codes", () => {
@@ -1352,6 +1461,15 @@ describe("copy for the generic server codes", () => {
expect(humanizeError({ status: 400, code: "bad_request", message: "" })).toBe("Something went wrong."); expect(humanizeError({ status: 400, code: "bad_request", message: "" })).toBe("Something went wrong.");
}); });
it("words the file manager's refusals itself", () => {
expect(humanizeError({ status: 409, code: "file_exists", message: "something is already at a.txt" })).toBe(
"Something with that name is already there. Pick another name, or rename or delete the one that is there first.",
);
expect(humanizeError({ status: 507, code: "upload_staging_full", message: "raw" })).toMatch(/upload space is nearly full/);
expect(humanizeError({ status: 400, code: "upload_incomplete", message: "raw" })).toMatch(/stopped before the whole file arrived/);
expect(humanizeError({ status: 411, code: "length_required", message: "raw" })).toMatch(/did not say how large it is/);
});
it("reads a full upload store as full, not as an outage", () => { it("reads a full upload store as full, not as an outage", () => {
expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/); expect(humanizeError({ status: 507, code: "uploads_full", message: "" })).toMatch(/upload store is full/);
}); });
+68 -2
View File
@@ -681,8 +681,8 @@ export const api = rejectingSync({
urlPath`/servers/${name}/jobs`, urlPath`/servers/${name}/jobs`,
).then((r) => r.jobs ?? []), ).then((r) => r.jobs ?? []),
// Server file editor (spec §7). All three routes are owner-or-admin gated and // Server file manager (spec §7). Every route is owner-or-admin gated and
// refuse with 409 not_stopped unless the server is fully stopped (the world // refuses with 409 not_stopped unless the server is fully stopped (the world
// volume is RWO), so callers gate on phase === "Stopped". The path travels as a // volume is RWO), so callers gate on phase === "Stopped". The path travels as a
// query parameter — a file path contains "/" and never round-trips through a // query parameter — a file path contains "/" and never round-trips through a
// path segment. Content is []byte on the wire, which Go's encoding/json renders // path segment. Content is []byte on the wire, which Go's encoding/json renders
@@ -714,6 +714,59 @@ export const api = rejectingSync({
expectSha256 ? { content, expect_sha256: expectSha256 } : { content }, expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
), ),
// createServerFile makes a new file with content, and only if nothing is at the
// path yet: something that appeared meanwhile is 409 file_exists, never replaced.
createServerFile: (name: string, path: string, content: string) =>
request<{ path: string; status: string; sha256: string }>(
"PUT",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
{ content, create_only: true },
),
// deleteServerFile deletes a file, a link (never what it names) or a folder with
// everything in it. The route does not ask; the page confirms first.
deleteServerFile: (name: string, path: string) =>
request<{ path: string; status: string }>(
"DELETE",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
),
// mkdirServerFolder makes one folder in an existing parent (404 otherwise);
// anything already at the path is 409 file_exists.
mkdirServerFolder: (name: string, path: string) =>
request<{ path: string; status: string }>(
"POST",
urlPath`/servers/${name}/files/mkdir` + `?path=${encodeURIComponent(path)}`,
),
// renameServerFile moves path to `to`, never over something: an existing `to`
// is 409 file_exists. The files felis manages refuse with 400 bad_path.
renameServerFile: (name: string, path: string, to: string) =>
request<{ path: string; to: string; status: string }>(
"POST",
urlPath`/servers/${name}/files/rename` + `?path=${encodeURIComponent(path)}`,
{ to },
),
// uploadServerFile sends a file's raw bytes (the browser sets Content-Length
// from the Blob) with progress. Without overwrite an existing file is 409
// file_exists; with it the file is replaced whole or not at all.
uploadServerFile: (
name: string,
path: string,
file: Blob,
overwrite: boolean,
opts?: { onProgress?: (sent: number) => void; signal?: AbortSignal },
) =>
sendWithProgress<{ path: string; status: string; sha256: string; size: number }>(
"PUT",
urlPath`/servers/${name}/files/upload` +
`?path=${encodeURIComponent(path)}` +
(overwrite ? "&overwrite=true" : ""),
file,
opts,
),
// Account linking (spec §10). Both are POST: start reports status from the // Account linking (spec §10). Both are POST: start reports status from the
// session principal (no body, side-effect-free), verify consumes a code the // session principal (no body, side-effect-free), verify consumes a code the
// player was shown in-game. The panel can never mint a code — that is the // player was shown in-game. The panel can never mint a code — that is the
@@ -1168,6 +1221,19 @@ export function humanizeError(e: unknown): string {
return t("files_timeout"); return t("files_timeout");
case "files_unavailable": case "files_unavailable":
return t("files_unavailable"); return t("files_unavailable");
// File manager: a create, folder, rename or upload never replaces what is
// already at the path (unless an upload asked to).
case "file_exists":
return t("file_exists");
// Uploads are staged on felis-api's disk before the file Job lands them: that
// disk can be at its floor (507), the body can stop short of its length, or a
// client can send none.
case "upload_staging_full":
return t("upload_staging_full");
case "upload_incomplete":
return t("upload_incomplete");
case "length_required":
return t("length_required");
case "jobs_unavailable": case "jobs_unavailable":
return t("jobs_unavailable"); return t("jobs_unavailable");
// Builds, uploads and review: terminal-state conflicts and unwired subsystems. // Builds, uploads and review: terminal-state conflicts and unwired subsystems.
+433 -1
View File
@@ -139,6 +139,26 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/api/v1/internal/file-uploads/{id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Stream one staged file upload to the Job landing it (one-time bearer token).
* @description PUT /api/v1/servers/{name}/files/upload stages the body on felis-api's disk and creates a Job to land it in the world volume; the Job fetches the bytes here. The Job holds no service token, so the route is public on the internal face and the bearer token minted with the upload is the whole check. The token opens its upload once. An unknown id, a wrong or missing token and a spent token are all the same 404, so the route says nothing about which uploads exist.
*/
get: operations["internalFileUpload"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/internal/servers/{name}/join-event": { "/api/v1/internal/servers/{name}/join-event": {
parameters: { parameters: {
query?: never; query?: never;
@@ -1292,6 +1312,70 @@ export interface paths {
*/ */
put: operations["writeServerFile"]; put: operations["writeServerFile"];
post?: never; post?: never;
/**
* Delete a file or folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Deletes a file, a symlink (never what it points at) or a folder with everything in it. The world root itself is refused (400 bad_path). Same stopped-gate, world lock and os.Root containment as a write. The panel confirms first; this route does not. Audited as file.delete.
*/
delete: operations["deleteServerFile"];
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/mkdir": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Make a folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Makes one folder. Its parent must already exist (404), and nothing may be at the path yet (409 file_exists). Same stopped-gate, world lock and os.Root containment as a write. Audited as file.mkdir.
*/
post: operations["makeServerFolder"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/rename": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
put?: never;
/**
* Move or rename a file or folder in a server's world volume (owner-or-admin; server must be stopped).
* @description Moves the file or folder at path to to. It never replaces: an existing destination is 409 file_exists, and a missing destination folder is 404. server.properties, config/paper-global.yml and config/ cannot be moved under any name they are reached by (400 bad_path), because elsewhere the read path would no longer withhold their secrets. Same stopped-gate, world lock and os.Root containment as a write. Audited as file.rename.
*/
post: operations["renameServerFile"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/servers/{name}/files/upload": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/**
* Upload a file into a server's world volume (owner-or-admin; server must be stopped).
* @description Lands the raw request body as the file at path, up to 64 MiB — a plugin jar, a datapack, a world region. Content-Length is required (411 length_required). An existing file is 409 file_exists unless overwrite=true; a folder at the path is 400 bad_path either way. The body is staged on felis-api's disk first and then fetched by the file Job with a one-time token, so the world lock is taken only after the body has arrived and a slow upload holds off no backup. The file lands atomically: a synced temporary sibling is checked against the staged size and SHA-256, then renamed into place, so a failed upload leaves the old file whole. Same stopped-gate and os.Root containment as a write. Audited as file.upload.
*/
put: operations["uploadServerFile"];
post?: never;
delete?: never; delete?: never;
options?: never; options?: never;
head?: never; head?: never;
@@ -3246,6 +3330,32 @@ export interface operations {
503: components["responses"]["ServiceUnavailable"]; 503: components["responses"]["ServiceUnavailable"];
}; };
}; };
internalFileUpload: {
parameters: {
query?: never;
header: {
/** @description Bearer followed by the token minted with the upload. */
Authorization: string;
};
path: {
id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The staged bytes, verbatim, with their Content-Length. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/octet-stream": string;
};
};
404: components["responses"]["NotFound"];
};
};
joinEvent: { joinEvent: {
parameters: { parameters: {
query?: never; query?: never;
@@ -6130,6 +6240,8 @@ export interface operations {
content: string; content: string;
/** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */ /** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */
expect_sha256?: string; expect_sha256?: string;
/** @description true writes only if nothing is at the path yet (409 file_exists otherwise), for making a new file without replacing one that appeared meanwhile. Cannot be combined with expect_sha256. */
create_only?: boolean;
}; };
}; };
}; };
@@ -6169,7 +6281,7 @@ export interface operations {
"application/json": components["schemas"]["Error"]; "application/json": components["schemas"]["Error"];
}; };
}; };
/** @description Server is not stopped (not_stopped), or a restore, backup or file write already holds its world volume (maintenance_in_progress). */ /** @description The file changed since expect_sha256 was read (file_changed), something is already at the path with create_only (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: { 409: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;
@@ -6208,6 +6320,326 @@ export interface operations {
}; };
}; };
}; };
deleteServerFile: {
parameters: {
query: {
/** @description File or folder to delete, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Deleted. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "deleted";
};
};
};
/** @description Missing path, invalid server name, the world root, or a path that escapes it. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or nothing at the path. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
makeServerFolder: {
parameters: {
query: {
/** @description Folder to make, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Folder made. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "created";
};
};
};
/** @description Missing path, invalid server name, the world root, or a path that escapes it. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or the parent folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Something is already at the path (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
renameServerFile: {
parameters: {
query: {
/** @description File or folder to move, relative to the world root. */
path: string;
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": {
/** @description The new path */
to: string;
};
};
};
responses: {
/** @description Moved. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
to: string;
/** @constant */
status: "renamed";
};
};
};
/** @description Missing path or to, malformed body, invalid server name, the world root, a file felis manages, or a path that escapes the world root. */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, nothing at path, or the destination folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description Something is already at to (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
uploadServerFile: {
parameters: {
query: {
/** @description File to create, relative to the world root. Its folder must exist. */
path: string;
/** @description true replaces an existing file, keeping its mode. Anything else refuses to. */
overwrite?: "true" | "false";
};
header?: never;
path: {
name: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/octet-stream": string;
};
};
responses: {
/** @description File uploaded. */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": {
path: string;
/** @constant */
status: "uploaded";
/** @description SHA-256 of the bytes landed. */
sha256: string;
/**
* Format: int64
* @description Bytes landed.
*/
size: number;
};
};
};
/** @description Missing path, invalid server name, a folder or the world root at the path, a path that escapes the world root, or a body that ended before Content-Length bytes arrived (upload_incomplete). */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
401: components["responses"]["Unauthorized"];
403: components["responses"]["Forbidden"];
/** @description Unknown server, or the folder does not exist. */
404: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description A file is already at the path and overwrite is not true (file_exists), the server is not stopped (not_stopped), or a restore, backup or file change already holds its world volume (maintenance_in_progress). */
409: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The request has no Content-Length (length_required). */
411: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description The file is over 64 MiB (too_large). */
413: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */
504: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
/** @description felis-api's staging disk has no room for the upload right now (upload_staging_full), or the world volume has no room for it (volume_full); nothing was changed. */
507: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["Error"];
};
};
};
};
listUsers: { listUsers: {
parameters: { parameters: {
query?: { query?: {
+520 -2
View File
@@ -1,10 +1,12 @@
// @vitest-environment jsdom // @vitest-environment jsdom
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { act, render, screen, waitFor, within } from "@testing-library/react"; import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event"; import userEvent from "@testing-library/user-event";
import { MemoryRouter, Route, Routes } from "react-router-dom"; import { MemoryRouter, Route, Routes } from "react-router-dom";
import i18next from "i18next"; import i18next from "i18next";
import { ServerFiles } from "./ServerFiles"; import { ServerFiles } from "./ServerFiles";
import { humanizeError } from "@/lib/api";
import { MAX_UPLOAD_BYTES } from "@/components/files/useUploads";
import { STATUS_POLL_FAST_MS } from "@/lib/hooks"; import { STATUS_POLL_FAST_MS } from "@/lib/hooks";
const mocks = vi.hoisted(() => ({ const mocks = vi.hoisted(() => ({
@@ -13,6 +15,11 @@ const mocks = vi.hoisted(() => ({
stop: vi.fn(), stop: vi.fn(),
listServerFiles: vi.fn(), listServerFiles: vi.fn(),
readServerFile: vi.fn(), readServerFile: vi.fn(),
createServerFile: vi.fn(),
deleteServerFile: vi.fn(),
mkdirServerFolder: vi.fn(),
renameServerFile: vi.fn(),
uploadServerFile: vi.fn(),
})); }));
vi.mock("@/lib/api", async (importOriginal) => { vi.mock("@/lib/api", async (importOriginal) => {
@@ -26,6 +33,11 @@ vi.mock("@/lib/api", async (importOriginal) => {
listServerFiles: mocks.listServerFiles, listServerFiles: mocks.listServerFiles,
readServerFile: mocks.readServerFile, readServerFile: mocks.readServerFile,
writeServerFile: mocks.writeServerFile, writeServerFile: mocks.writeServerFile,
createServerFile: mocks.createServerFile,
deleteServerFile: mocks.deleteServerFile,
mkdirServerFolder: mocks.mkdirServerFolder,
renameServerFile: mocks.renameServerFile,
uploadServerFile: mocks.uploadServerFile,
}, },
}; };
}); });
@@ -34,7 +46,7 @@ vi.mock("@/lib/tier", () => ({ useTier: () => ({ isAdmin: true, loading: false }
const t = (key: string) => i18next.t(key); const t = (key: string) => i18next.t(key);
function renderFiles() { function renderFiles() {
render( return render(
<MemoryRouter initialEntries={["/servers/lobby/files"]}> <MemoryRouter initialEntries={["/servers/lobby/files"]}>
<Routes> <Routes>
<Route path="/servers/:name/files" element={<ServerFiles />} /> <Route path="/servers/:name/files" element={<ServerFiles />} />
@@ -69,6 +81,9 @@ beforeEach(() => {
mocks.readServerFile.mockImplementation((_name: string, path: string) => mocks.readServerFile.mockImplementation((_name: string, path: string) =>
Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }), Promise.resolve({ path, content: btoa("motd=hi\n"), sha256: "abc" }),
); );
for (const m of [mocks.createServerFile, mocks.deleteServerFile, mocks.mkdirServerFolder, mocks.renameServerFile, mocks.uploadServerFile]) {
m.mockReset();
}
mocks.stop.mockReset(); mocks.stop.mockReset();
mocks.status.mockReset(); mocks.status.mockReset();
mocks.status.mockResolvedValue(stopped); mocks.status.mockResolvedValue(stopped);
@@ -290,3 +305,506 @@ describe("ServerFiles folder answers arriving out of order", () => {
expect(refresh().disabled).toBe(false); expect(refresh().disabled).toBe(false);
}); });
}); });
const button = (key: string, name?: string) =>
screen.getByRole("button", { name: i18next.t(`files:${key}`, name === undefined ? {} : { name }) }) as HTMLButtonElement;
describe("ServerFiles changes", () => {
it("offers no rename for what Felis manages, and one for everything else", async () => {
renderFiles();
await screen.findByText("world");
expect(button("rename_item", "server.properties").disabled).toBe(true);
expect(button("rename_item", "world").disabled).toBe(false);
expect(button("delete_item", "server.properties").disabled).toBe(false);
});
it("renames within the folder and rereads it", async () => {
mocks.renameServerFile.mockResolvedValue({ path: "world/world", to: "world/world_old", status: "renamed" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
const field = within(dialog).getByRole("textbox") as HTMLInputElement;
expect(field.value).toBe("world");
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.clear(field);
await userEvent.type(field, " world_old {Enter}");
expect(mocks.renameServerFile.mock.calls).toEqual([["lobby", "world/world", "world/world_old"]]);
expect(await screen.findByText(i18next.t("files:renamed", { from: "world", to: "world_old" }))).toBeTruthy();
expect(screen.queryByRole("dialog")).toBeNull();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("keeps rename off until the name changes", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:rename_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
const submit = within(dialog).getByRole("button", { name: t("files:rename") }) as HTMLButtonElement;
expect(submit.disabled).toBe(true);
await userEvent.type(within(dialog).getByRole("textbox"), "2");
expect(submit.disabled).toBe(false);
});
it("says why a name cannot be used and sends nothing", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
const field = within(dialog).getByRole("textbox");
const submit = within(dialog).getByRole("button", { name: t("files:create") }) as HTMLButtonElement;
// An empty field has nothing to say yet, and nothing to create.
expect(within(dialog).queryByText(t("files:name_required"))).toBeNull();
expect(submit.disabled).toBe(true);
await userEvent.type(field, "world");
expect(within(dialog).getByText(t("files:name_taken"))).toBeTruthy();
expect(submit.disabled).toBe(true);
await userEvent.clear(field);
await userEvent.type(field, "a/b{Enter}");
expect(within(dialog).getByText(t("files:name_slash"))).toBeTruthy();
expect(field.getAttribute("aria-invalid")).toBe("true");
expect(mocks.mkdirServerFolder).not.toHaveBeenCalled();
});
it("makes a folder in the folder on screen", async () => {
mocks.mkdirServerFolder.mockResolvedValue({ path: "world/datapacks", status: "created" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: t("files:new_folder") }));
await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "datapacks{Enter}");
expect(mocks.mkdirServerFolder.mock.calls).toEqual([["lobby", "world/datapacks"]]);
expect(await screen.findByText(i18next.t("files:folder_created", { path: "world/datapacks" }))).toBeTruthy();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("sends a name once, and stays open until the answer comes", async () => {
let answer!: (v: unknown) => void;
mocks.mkdirServerFolder.mockReturnValue(new Promise((res) => (answer = res)));
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
await userEvent.type(within(dialog).getByRole("textbox"), "datapacks");
const create = within(dialog).getByRole("button", { name: t("files:create") });
await userEvent.click(create);
await userEvent.click(create);
await userEvent.keyboard("{Escape}");
expect(mocks.mkdirServerFolder).toHaveBeenCalledTimes(1);
expect(screen.getByRole("dialog")).toBe(dialog);
await act(async () => answer({ path: "datapacks", status: "created" }));
await waitFor(() => expect(screen.queryByRole("dialog")).toBeNull());
});
it("keeps the dialog open with the server's refusal", async () => {
const refusal = { status: 409, code: "file_exists", message: "something is already at datapacks" };
mocks.mkdirServerFolder.mockRejectedValue(refusal);
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_folder") }));
const dialog = screen.getByRole("dialog");
await userEvent.type(within(dialog).getByRole("textbox"), "datapacks{Enter}");
expect(await within(dialog).findByText(humanizeError(refusal))).toBeTruthy();
expect(screen.getByRole("dialog")).toBe(dialog);
});
it("opens a new file in the editor, and its first save expects the file it made", async () => {
mocks.createServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "e".repeat(64) });
mocks.writeServerFile.mockResolvedValue({ path: "notes.txt", status: "written", sha256: "f".repeat(64) });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: t("files:new_file") }));
await userEvent.type(within(screen.getByRole("dialog")).getByRole("textbox"), "notes.txt{Enter}");
expect(mocks.createServerFile.mock.calls).toEqual([["lobby", "notes.txt", ""]]);
const editor = await screen.findByRole("dialog");
expect(within(editor).getByText("notes.txt")).toBeTruthy();
await userEvent.type(within(editor).getByRole("textbox"), "hi");
await userEvent.click(within(editor).getByRole("button", { name: t("files:save") }));
expect(mocks.writeServerFile.mock.calls).toEqual([["lobby", "notes.txt", btoa("hi"), "e".repeat(64)]]);
});
it("asks before deleting a folder with everything in it, then rereads", async () => {
mocks.deleteServerFile.mockResolvedValue({ path: "world/world", status: "deleted" });
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:delete_item", { name: "world" }) }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(i18next.t("files:delete_folder_title", { name: "world" }))).toBeTruthy();
expect(within(dialog).getByText(t("files:delete_folder_body"))).toBeTruthy();
expect(mocks.deleteServerFile).not.toHaveBeenCalled();
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(within(dialog).getByRole("button", { name: t("files:delete") }));
expect(mocks.deleteServerFile.mock.calls).toEqual([["lobby", "world/world"]]);
expect(await screen.findByText(i18next.t("files:deleted", { path: "world/world" }))).toBeTruthy();
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
});
it("words a file's deletion as a file's", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:delete_item", { name: "server.properties" }) }));
const dialog = screen.getByRole("dialog");
expect(within(dialog).getByText(i18next.t("files:delete_file_title", { name: "server.properties" }))).toBeTruthy();
expect(within(dialog).getByText(t("files:delete_file_body"))).toBeTruthy();
// The row's own click never fired: the file did not open behind it.
expect(mocks.readServerFile).not.toHaveBeenCalled();
});
it("says why the proxy secret's file does not open, and reads nothing", async () => {
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
Promise.resolve({
path,
truncated: false,
entries:
path === "config"
? [{ name: "paper-global.yml", size: 900, is_dir: false, mod_time: "2026-09-01T00:00:00Z" }]
: [{ name: "config", size: 0, is_dir: true, mod_time: "2026-09-01T00:00:00Z" }],
}),
);
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "config" }) }));
await userEvent.click(await screen.findByText("paper-global.yml"));
expect(screen.getByText(t("files:secret_config_unreadable"))).toBeTruthy();
expect(mocks.readServerFile).not.toHaveBeenCalled();
expect(button("rename_item", "paper-global.yml").disabled).toBe(true);
});
it("lists folders first and numbers in counting order", async () => {
mocks.listServerFiles.mockResolvedValue({
path: "",
truncated: false,
entries: ["r.10.0.mca", "world", "r.2.0.mca", "logs"].map((name) => ({
name,
size: 1,
is_dir: !name.endsWith(".mca"),
mod_time: "2026-09-01T00:00:00Z",
})),
});
renderFiles();
await screen.findByText("logs");
const names = screen
.getAllByRole("button", { name: /^(Open|打开)/ })
.map((b) => b.textContent);
expect(names).toEqual(["logs", "world", "r.2.0.mca", "r.10.0.mca"]);
});
});
describe("ServerFiles uploads", () => {
type Pending = {
resolve: () => void;
reject: (e: unknown) => void;
signal?: AbortSignal;
progress?: (sent: number) => void;
};
let pending: Pending[];
beforeEach(() => {
pending = [];
mocks.uploadServerFile.mockImplementation(
(
_name: string,
path: string,
file: File,
_overwrite: boolean,
opts?: { signal?: AbortSignal; onProgress?: (sent: number) => void },
) =>
new Promise((resolve, reject) => {
pending.push({
resolve: () => resolve({ path, status: "uploaded", sha256: "a", size: file.size }),
reject,
signal: opts?.signal,
progress: opts?.onProgress,
});
opts?.signal?.addEventListener("abort", () => reject(new DOMException("cancelled", "AbortError")));
}),
);
});
const file = (name: string, body = "bytes") => new File([body], name);
const pick = (...files: File[]) => fireEvent.change(screen.getByTestId("upload-input"), { target: { files } });
const queue = () => screen.getByRole("region", { name: t("files:uploads_label") });
const sentAs = () => mocks.uploadServerFile.mock.calls.map((c) => [c[1], c[3]]);
it("sends one at a time and rereads the folder once the queue is empty", async () => {
renderFiles();
await screen.findByText("world");
const lists = mocks.listServerFiles.mock.calls.length;
pick(file("a.jar"), file("b.jar"));
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
expect(within(queue()).getByText(t("files:upload_queued"))).toBeTruthy();
await act(async () => pending[0].resolve());
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false], ["b.jar", false]]));
expect(mocks.listServerFiles.mock.calls.length).toBe(lists);
await act(async () => pending[1].resolve());
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1));
expect(within(queue()).getAllByText(t("files:upload_done"))).toHaveLength(2);
});
it("rereads once at the end even when the folder changes on the way", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"), file("b.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].resolve());
await waitFor(() => expect(pending).toHaveLength(2));
const lists = mocks.listServerFiles.mock.calls.length;
await userEvent.click(screen.getByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 1);
await act(async () => pending[1].resolve());
await waitFor(() => expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 2));
expect(mocks.listServerFiles).toHaveBeenLastCalledWith("lobby", "world");
// That reread was the landing's: going back is one listing, as always.
await userEvent.click(screen.getByRole("button", { name: t("files:up") }));
await waitFor(() => expect(screen.queryByRole("button", { name: "world" })).toBeNull());
expect(mocks.listServerFiles.mock.calls.length).toBe(lists + 3);
});
it("lands in the folder on screen when it was added", async () => {
renderFiles();
await userEvent.click(await screen.findByRole("button", { name: i18next.t("files:open_folder", { name: "world" }) }));
await screen.findByRole("button", { name: "world" });
pick(file("level.dat_old"));
await waitFor(() => expect(sentAs()).toEqual([["world/level.dat_old", false]]));
});
it("waits on a file already there until told to replace it", async () => {
renderFiles();
await screen.findByText("world");
pick(file("server.properties"));
expect(await within(queue()).findByText(t("files:upload_exists"))).toBeTruthy();
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace") }));
await waitFor(() => expect(sentAs()).toEqual([["server.properties", true]]));
});
it("drops a skipped file without sending it", async () => {
renderFiles();
await screen.findByText("world");
pick(file("server.properties"));
await userEvent.click(await within(queue()).findByRole("button", { name: t("files:upload_skip") }));
expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull();
expect(mocks.uploadServerFile).not.toHaveBeenCalled();
});
it("refuses a name a folder holds, and a file over the limit, without sending or offering a retry", async () => {
renderFiles();
await screen.findByText("world");
const big = file("world-backup.zip");
Object.defineProperty(big, "size", { value: MAX_UPLOAD_BYTES + 1 });
const exact = file("exact.zip");
Object.defineProperty(exact, "size", { value: MAX_UPLOAD_BYTES });
pick(file("world"), big, exact);
expect(await within(queue()).findByText(t("files:upload_folder_there"))).toBeTruthy();
expect(within(queue()).getByText(i18next.t("files:upload_too_large", { limit: "64 MiB" }))).toBeTruthy();
expect(within(queue()).queryByRole("button", { name: t("files:upload_retry") })).toBeNull();
await waitFor(() => expect(sentAs()).toEqual([["exact.zip", false]]));
});
it("asks about a file that appeared since the listing", async () => {
renderFiles();
await screen.findByText("world");
pick(file("icon.png"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].reject({ status: 409, code: "file_exists", message: "something is already at icon.png" }));
expect(await within(queue()).findByRole("button", { name: t("files:upload_replace") })).toBeTruthy();
expect(within(queue()).getByText(t("files:upload_exists"))).toBeTruthy();
});
it("offers a retry after a failure and sends it again", async () => {
const full = { status: 507, code: "volume_full", message: "no space left" };
renderFiles();
await screen.findByText("world");
pick(file("big.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].reject(full));
expect(await within(queue()).findByText(humanizeError(full))).toBeTruthy();
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_retry") }));
await waitFor(() => expect(sentAs()).toEqual([["big.jar", false], ["big.jar", false]]));
});
it("cancels the upload in flight and rereads nothing", async () => {
renderFiles();
await screen.findByText("world");
const lists = mocks.listServerFiles.mock.calls.length;
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
await userEvent.click(within(queue()).getByRole("button", { name: i18next.t("files:upload_cancel", { name: "a.jar" }) }));
expect(pending[0].signal?.aborted).toBe(true);
await waitFor(() => expect(screen.queryByRole("region", { name: t("files:uploads_label") })).toBeNull());
expect(mocks.listServerFiles.mock.calls.length).toBe(lists);
});
it("stops the upload in flight when the page goes away", async () => {
const { unmount } = renderFiles();
await screen.findByText("world");
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
unmount();
expect(pending[0].signal?.aborted).toBe(true);
});
it("holds every other change, and leaving the page, while an upload runs", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"));
await waitFor(() => expect(pending).toHaveLength(1));
for (const b of [button("new_file"), button("new_folder"), button("rename_item", "world"), button("delete_item", "world")]) {
expect(b.disabled).toBe(true);
}
const leave = new Event("beforeunload", { cancelable: true });
window.dispatchEvent(leave);
expect(leave.defaultPrevented).toBe(true);
await act(async () => pending[0].resolve());
await waitFor(() => expect(button("new_file").disabled).toBe(false));
expect(button("delete_item", "world").disabled).toBe(false);
});
describe("with several files already there", () => {
beforeEach(() => {
mocks.listServerFiles.mockImplementation((_name: string, path: string) =>
Promise.resolve({
path,
truncated: false,
entries: ["a.yml", "b.yml"].map((name) => ({ name, size: 8, is_dir: false, mod_time: "2026-09-01T00:00:00Z" })),
}),
);
});
it("replaces every waiting file at once", async () => {
renderFiles();
await screen.findByText("a.yml");
pick(file("a.yml"), file("b.yml"), file("c.yml"));
await waitFor(() => expect(sentAs()).toEqual([["c.yml", false]]));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_replace_all") }));
await act(async () => pending[0].resolve());
await waitFor(() => expect(pending).toHaveLength(2));
await act(async () => pending[1].resolve());
await waitFor(() => expect(sentAs()).toEqual([["c.yml", false], ["a.yml", true], ["b.yml", true]]));
});
it("skips every waiting file at once and keeps the rest", async () => {
renderFiles();
await screen.findByText("a.yml");
pick(file("a.yml"), file("b.yml"), file("c.yml"));
await waitFor(() => expect(pending).toHaveLength(1));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_skip_all") }));
expect(within(queue()).queryByText(t("files:upload_exists"))).toBeNull();
expect(within(queue()).getByText("c.yml")).toBeTruthy();
expect(sentAs()).toEqual([["c.yml", false]]);
});
});
it("clears the finished uploads and keeps the ones still asking", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar"), file("server.properties"));
await waitFor(() => expect(pending).toHaveLength(1));
await act(async () => pending[0].resolve());
await within(queue()).findByText(t("files:upload_done"));
await userEvent.click(within(queue()).getByRole("button", { name: t("files:upload_clear_done") }));
expect(within(queue()).queryByText("a.jar")).toBeNull();
expect(within(queue()).getByText("server.properties")).toBeTruthy();
});
it("shows the file being written once all of it is sent, and no longer offers a cancel", async () => {
renderFiles();
await screen.findByText("world");
pick(file("a.jar", "0123456789"));
await waitFor(() => expect(pending).toHaveLength(1));
const cancel = i18next.t("files:upload_cancel", { name: "a.jar" });
await act(async () => pending[0].progress?.(4));
expect(within(queue()).getByRole("progressbar").getAttribute("aria-valuenow")).toBe("40");
expect(within(queue()).getByRole("button", { name: cancel })).toBeTruthy();
await act(async () => pending[0].progress?.(10));
expect(within(queue()).getByText(t("files:upload_landing"))).toBeTruthy();
expect(within(queue()).queryByRole("button", { name: cancel })).toBeNull();
});
it("takes dropped files and names the folders it skipped", async () => {
renderFiles();
const table = await screen.findByRole("table");
const dataTransfer = {
types: ["Files"],
files: [],
items: [
{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: true }), getAsFile: () => null },
{ kind: "file", webkitGetAsEntry: () => ({ isDirectory: false }), getAsFile: () => file("a.jar") },
],
};
fireEvent.drop(table, { dataTransfer });
await waitFor(() => expect(sentAs()).toEqual([["a.jar", false]]));
expect(screen.getByText(i18next.t("files:upload_no_folders", { count: 1 }))).toBeTruthy();
});
it("keeps a file dropped beside the list from replacing the page", async () => {
renderFiles();
const table = await screen.findByRole("table");
const beside = { types: ["Files"], dropEffect: "move" };
const onList = { types: ["Files"], dropEffect: "move" };
expect(fireEvent.dragOver(document.body, { dataTransfer: beside })).toBe(false);
expect(beside.dropEffect).toBe("none");
expect(fireEvent.dragOver(table, { dataTransfer: onList })).toBe(false);
expect(onList.dropEffect).toBe("copy");
// Dragging text is the browser's own business.
expect(fireEvent.dragOver(document.body, { dataTransfer: { types: ["text/plain"], dropEffect: "move" } })).toBe(true);
});
});
+313 -23
View File
@@ -4,13 +4,18 @@ import {
AlertTriangle, AlertTriangle,
ArrowUp, ArrowUp,
ChevronRight, ChevronRight,
FilePlus,
FileText, FileText,
Folder, Folder,
FolderOpen, FolderOpen,
FolderPlus,
Loader2, Loader2,
Pencil,
RefreshCw, RefreshCw,
Save, Save,
Square, Square,
Trash2,
Upload,
} from "lucide-react"; } from "lucide-react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { BackLink } from "@/components/BackLink"; import { BackLink } from "@/components/BackLink";
@@ -18,6 +23,19 @@ import { Button } from "@/components/ui/button";
import { Card, CardContent } from "@/components/ui/card"; import { Card, CardContent } from "@/components/ui/card";
import { MessageLine } from "@/components/MessageLine"; import { MessageLine } from "@/components/MessageLine";
import { InlineConfirm } from "@/components/InlineConfirm"; import { InlineConfirm } from "@/components/InlineConfirm";
import { ConfirmDialog } from "@/components/ConfirmDialog";
import { NameDialog } from "@/components/files/NameDialog";
import { UploadQueue } from "@/components/files/UploadQueue";
import { MAX_UPLOAD_BYTES, useUploads } from "@/components/files/useUploads";
import {
SECRET_CONFIG_PATH,
isManaged,
joinPath,
nameProblem,
parentOf,
sortEntries,
type NameProblem,
} from "@/components/files/names";
import { import {
Dialog, Dialog,
DialogContent, DialogContent,
@@ -73,19 +91,16 @@ function decodeText(bytes: Uint8Array): string | null {
} }
} }
function joinPath(dir: string, name: string): string { /** The name questions the page asks, each tied to the folder it was asked in. */
return dir === "" ? name : `${dir}/${name}`; type Naming =
} | { kind: "file" | "folder"; dir: string }
| { kind: "rename"; dir: string; entry: ServerFileEntry };
function parentOf(dir: string): string { /** ServerFiles is the world-volume file manager: browse, edit a config (the "one
const i = dir.lastIndexOf("/"); * wrong line in server.properties" repair), make, rename, delete and upload.
return i === -1 ? "" : dir.slice(0, i); * Every call is owner-or-admin gated and refused with 409 not_stopped unless the
} * server is fully stopped (the world volume is RWO), so the page gates up front
* instead of letting each call fail. */
/** ServerFiles is the world-volume file editor (the "one wrong line in
* server.properties" repair). Every call is owner-or-admin gated and refused
* with 409 not_stopped unless the server is fully stopped (the world volume is
* RWO), so the page gates up front instead of letting each call fail. */
export function ServerFiles() { export function ServerFiles() {
const { name = "" } = useParams(); const { name = "" } = useParams();
const { t, i18n } = useTranslation("files"); const { t, i18n } = useTranslation("files");
@@ -120,7 +135,7 @@ export function ServerFiles() {
try { try {
const r = await api.listServerFiles(name, p); const r = await api.listServerFiles(name, p);
if (ticket !== loadSeq.current) return; if (ticket !== loadSeq.current) return;
setEntries(r.entries ?? []); setEntries(sortEntries(r.entries ?? []));
setTruncated(r.truncated === true); setTruncated(r.truncated === true);
setDir(p); setDir(p);
} catch (e) { } catch (e) {
@@ -201,6 +216,12 @@ export function ServerFiles() {
async function openFile(entry: ServerFileEntry) { async function openFile(entry: ServerFileEntry) {
const p = joinPath(dir, entry.name); const p = joinPath(dir, entry.name);
// The read path refuses this one outright; saying why beats a Job that
// answers "invalid path".
if (p === SECRET_CONFIG_PATH) {
setMsg({ kind: "error", text: t("secret_config_unreadable") });
return;
}
setOpening(p); setOpening(p);
setMsg(null); setMsg(null);
try { try {
@@ -254,6 +275,115 @@ export function ServerFiles() {
} }
} }
// Uploads run one at a time. The listing is reread once the queue has drained
// rather than after each file: every listing is a Job of its own.
const landedSince = useRef(false);
const uploads = useUploads(name, () => {
landedSince.current = true;
});
useEffect(() => {
if (uploads.busy || !landedSince.current) return;
landedSince.current = false;
void load(dir);
}, [uploads.busy, dir, load]);
// A browser leaving the page takes the uploads with it.
useUnsavedGuard(uploads.busy);
// A file dropped anywhere else on the page would be opened by the browser in
// place of the panel, taking the queue with it; outside the list a drop does
// nothing. The list itself claims its drops first.
useEffect(() => {
const refuse = (e: DragEvent) => {
if (e.defaultPrevented || !e.dataTransfer?.types.includes("Files")) return;
e.preventDefault();
e.dataTransfer.dropEffect = "none";
};
window.addEventListener("dragover", refuse);
window.addEventListener("drop", refuse);
return () => {
window.removeEventListener("dragover", refuse);
window.removeEventListener("drop", refuse);
};
}, []);
const [naming, setNaming] = useState<Naming | null>(null);
// The entry outlives the dialog closing, so its title holds through the
// closing animation.
const [deleting, setDeleting] = useState<ServerFileEntry | null>(null);
const [deleteOpen, setDeleteOpen] = useState(false);
const fileInput = useRef<HTMLInputElement>(null);
// dragenter and dragleave fire for every child crossed, so the overlay counts
// them rather than flickering at each row.
const [dragDepth, setDragDepth] = useState(0);
// The name dialog mounts per question, so each one starts from its own name.
function ask(next: Naming) {
setMsg(null);
setNaming(next);
}
function problemText(p: NameProblem | null): string | null {
return p === null ? null : t(p);
}
function addFiles(files: readonly File[]) {
if (files.length === 0) return;
setMsg(null);
uploads.add(files, dir, entries);
}
// addDropped queues what was dropped. A folder arrives as an entry the browser
// cannot read as a file, so it is named as skipped instead of failing later.
function addDropped(data: DataTransfer) {
const files: File[] = [];
let folders = 0;
for (const item of Array.from(data.items ?? [])) {
if (item.kind !== "file") continue;
if (item.webkitGetAsEntry?.()?.isDirectory) {
folders++;
continue;
}
const f = item.getAsFile();
if (f) files.push(f);
}
if (data.items === undefined || data.items.length === 0) files.push(...Array.from(data.files));
addFiles(files);
if (folders > 0) setMsg({ kind: "error", text: t("upload_no_folders", { count: folders }) });
}
const draggingFiles = (e: React.DragEvent) => Array.from(e.dataTransfer.types).includes("Files");
async function createFile(n: string) {
if (!naming) return;
const p = joinPath(naming.dir, n);
const r = await api.createServerFile(name, p, "");
setConfirmDiscard(false);
setOpen({ path: p, text: "", original: "", editable: true, sha256: r.sha256 ?? "", conflict: false, error: null });
void load(naming.dir);
}
async function makeFolder(n: string) {
if (!naming) return;
const p = joinPath(naming.dir, n);
await api.mkdirServerFolder(name, p);
setMsg({ kind: "success", text: t("folder_created", { path: p }) });
void load(naming.dir);
}
async function renameEntry(n: string) {
if (naming?.kind !== "rename") return;
await api.renameServerFile(name, joinPath(naming.dir, naming.entry.name), joinPath(naming.dir, n));
setMsg({ kind: "success", text: t("renamed", { from: naming.entry.name, to: n }) });
void load(naming.dir);
}
async function deleteEntry() {
if (!deleting) return;
const p = joinPath(dir, deleting.name);
await api.deleteServerFile(name, p);
setMsg({ kind: "success", text: t("deleted", { path: p }) });
void load(dir);
}
const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />; const back = <BackLink to={`/servers/${name}`} label={t("back_to_console")} />;
if (statusQ.loading && !statusQ.data) { if (statusQ.loading && !statusQ.data) {
return ( return (
@@ -338,9 +468,39 @@ export function ServerFiles() {
) : listErr ? ( ) : listErr ? (
<ErrorState error={listErr} onRetry={() => load(dir)} /> <ErrorState error={listErr} onRetry={() => load(dir)} />
) : ( ) : (
<Card className="overflow-hidden"> <Card
className="relative overflow-hidden"
onDragEnter={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
setDragDepth((d) => d + 1);
}}
onDragOver={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
e.dataTransfer.dropEffect = "copy";
}}
onDragLeave={(e) => {
if (!draggingFiles(e)) return;
setDragDepth((d) => Math.max(0, d - 1));
}}
onDrop={(e) => {
if (!draggingFiles(e)) return;
e.preventDefault();
setDragDepth(0);
addDropped(e.dataTransfer);
}}
>
{dragDepth > 0 && (
<div className="pointer-events-none absolute inset-0 z-10 flex flex-col items-center justify-center gap-2 rounded-lg border-2 border-dashed border-primary bg-background/85 text-center backdrop-blur-[1px]">
<Upload className="h-7 w-7 text-primary" />
<p className="px-4 text-sm font-medium">
{t("drop_here", { dir: dir === "" ? t("root") : dir })}
</p>
</div>
)}
<CardContent className="p-0"> <CardContent className="p-0">
{/* Location bar: parent button + clickable breadcrumbs + refresh */} {/* Location bar: parent button + clickable breadcrumbs + actions */}
<div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3"> <div className="flex flex-wrap items-center gap-2 border-b border-border px-4 py-3">
<Button <Button
size="sm" size="sm"
@@ -351,7 +511,7 @@ export function ServerFiles() {
<ArrowUp className="h-4 w-4" /> <ArrowUp className="h-4 w-4" />
{t("up")} {t("up")}
</Button> </Button>
<nav className="flex flex-wrap items-center gap-1 text-sm"> <nav className="flex min-w-0 flex-wrap items-center gap-1 text-sm">
<button <button
type="button" type="button"
onClick={() => void load("")} onClick={() => void load("")}
@@ -382,7 +542,52 @@ export function ServerFiles() {
); );
})} })}
</nav> </nav>
<div className="ml-auto"> <div className="ml-auto flex items-center gap-1">
{/* Each change is a Job holding the world lock, so while an
upload holds it a change could only be refused. */}
<Button
size="sm"
variant="ghost"
onClick={() => ask({ kind: "file", dir })}
disabled={uploads.busy}
aria-label={t("new_file")}
title={uploads.busy ? t("wait_for_uploads") : t("new_file")}
>
<FilePlus className="h-4 w-4" />
<span className="hidden md:inline">{t("new_file")}</span>
</Button>
<Button
size="sm"
variant="ghost"
onClick={() => ask({ kind: "folder", dir })}
disabled={uploads.busy}
aria-label={t("new_folder")}
title={uploads.busy ? t("wait_for_uploads") : t("new_folder")}
>
<FolderPlus className="h-4 w-4" />
<span className="hidden md:inline">{t("new_folder")}</span>
</Button>
<Button
size="sm"
variant="outline"
onClick={() => fileInput.current?.click()}
aria-label={t("upload")}
title={t("upload_hint", { limit: formatBytes(MAX_UPLOAD_BYTES) })}
>
<Upload className="h-4 w-4" />
<span className="hidden sm:inline">{t("upload")}</span>
</Button>
<input
ref={fileInput}
type="file"
multiple
hidden
data-testid="upload-input"
onChange={(e) => {
addFiles(Array.from(e.target.files ?? []));
e.target.value = "";
}}
/>
<Button <Button
size="sm" size="sm"
variant="ghost" variant="ghost"
@@ -396,6 +601,17 @@ export function ServerFiles() {
</div> </div>
</div> </div>
<UploadQueue
items={uploads.items}
dir={dir}
onReplace={uploads.replace}
onRetry={uploads.retry}
onRemove={uploads.remove}
onReplaceAll={uploads.replaceAll}
onSkipAll={uploads.skipAll}
onClearDone={uploads.clearDone}
/>
{entries && entries.length === 0 ? ( {entries && entries.length === 0 ? (
<div className="p-4"> <div className="p-4">
<EmptyState title={t("empty_dir_title")} hint={t("empty_dir_hint")} /> <EmptyState title={t("empty_dir_title")} hint={t("empty_dir_hint")} />
@@ -407,14 +623,20 @@ export function ServerFiles() {
<tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground"> <tr className="border-b border-border bg-muted/40 text-left text-[11px] uppercase tracking-wider text-muted-foreground">
<th className="px-4 py-2.5 font-medium">{t("col_name")}</th> <th className="px-4 py-2.5 font-medium">{t("col_name")}</th>
<th className="px-4 py-2.5 font-medium">{t("col_size")}</th> <th className="px-4 py-2.5 font-medium">{t("col_size")}</th>
<th className="px-4 py-2.5 font-medium">{t("col_modified")}</th> <th className="hidden px-4 py-2.5 font-medium sm:table-cell">{t("col_modified")}</th>
<th className="w-px px-2 py-2.5">
<span className="sr-only">{t("col_actions")}</span>
</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-border"> <tbody className="divide-y divide-border">
{(entries ?? []).map((e) => ( {(entries ?? []).map((e) => {
const p = joinPath(dir, e.name);
const managed = isManaged(p);
return (
<tr <tr
key={e.name} key={e.name}
onClick={() => (e.is_dir ? void load(joinPath(dir, e.name)) : void openFile(e))} onClick={() => (e.is_dir ? void load(p) : void openFile(e))}
className="cursor-pointer transition-colors hover:bg-muted/30" className="cursor-pointer transition-colors hover:bg-muted/30"
> >
<td className="px-4 py-3"> <td className="px-4 py-3">
@@ -432,7 +654,7 @@ export function ServerFiles() {
<FileText className="h-4 w-4 shrink-0 text-muted-foreground" /> <FileText className="h-4 w-4 shrink-0 text-muted-foreground" />
)} )}
<span className="truncate font-mono text-xs">{e.name}</span> <span className="truncate font-mono text-xs">{e.name}</span>
{opening === joinPath(dir, e.name) && ( {opening === p && (
<Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" /> <Loader2 className="h-3.5 w-3.5 shrink-0 animate-spin text-muted-foreground" />
)} )}
</button> </button>
@@ -441,13 +663,52 @@ export function ServerFiles() {
{e.is_dir ? "—" : formatBytes(e.size)} {e.is_dir ? "—" : formatBytes(e.size)}
</td> </td>
<td <td
className="px-4 py-3 whitespace-nowrap text-xs text-muted-foreground" className="hidden px-4 py-3 whitespace-nowrap text-xs text-muted-foreground sm:table-cell"
title={e.mod_time} title={e.mod_time}
> >
{e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"} {e.mod_time ? formatRelative(e.mod_time, now, locale) : "—"}
</td> </td>
<td className="px-2 py-1.5">
{/* The actions stop at their own buttons: a click
here must not also open the row. */}
<div className="flex items-center justify-end gap-0.5" onClick={(ev) => ev.stopPropagation()}>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground hover:text-foreground"
onClick={() => ask({ kind: "rename", dir, entry: e })}
disabled={managed || uploads.busy}
aria-label={t("rename_item", { name: e.name })}
title={
managed
? t("managed_no_rename")
: uploads.busy
? t("wait_for_uploads")
: t("rename_item", { name: e.name })
}
>
<Pencil className="h-3.5 w-3.5" />
</Button>
<Button
size="sm"
variant="ghost"
className="h-8 w-8 p-0 text-muted-foreground hover:bg-destructive/10 hover:text-destructive"
onClick={() => {
setMsg(null);
setDeleting(e);
setDeleteOpen(true);
}}
disabled={uploads.busy}
aria-label={t("delete_item", { name: e.name })}
title={uploads.busy ? t("wait_for_uploads") : t("delete_item", { name: e.name })}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</div>
</td>
</tr> </tr>
))} );
})}
</tbody> </tbody>
</table> </table>
</div> </div>
@@ -575,6 +836,35 @@ export function ServerFiles() {
)} )}
</DialogContent> </DialogContent>
</Dialog> </Dialog>
{naming && (
<NameDialog
open
onOpenChange={(v) => !v && setNaming(null)}
title={
naming.kind === "rename"
? t("rename_title", { name: naming.entry.name })
: t(naming.kind === "file" ? "new_file" : "new_folder")
}
description={t("in_folder", { dir: naming.dir === "" ? t("root") : naming.dir })}
label={t("name_label")}
confirmLabel={t(naming.kind === "rename" ? "rename" : "create")}
initial={naming.kind === "rename" ? naming.entry.name : ""}
problem={(v) =>
problemText(nameProblem(v, entries, naming.kind === "rename" ? naming.entry.name : undefined))
}
onSubmit={naming.kind === "file" ? createFile : naming.kind === "folder" ? makeFolder : renameEntry}
/>
)}
<ConfirmDialog
open={deleteOpen}
onOpenChange={setDeleteOpen}
title={t(deleting?.is_dir ? "delete_folder_title" : "delete_file_title", { name: deleting?.name ?? "" })}
description={t(deleting?.is_dir ? "delete_folder_body" : "delete_file_body")}
confirmLabel={t("delete")}
onConfirm={deleteEntry}
/>
</> </>
); );
} }