fix(files): 配置文件改为同目录临时文件 fsync 后原子改名写入,读取返回内容哈希、保存带期望哈希冲突返回 409,磁盘满返回 507,面板提示载入最新或仍然覆盖
This commit is contained in:
17 files changed
+721
-131
No files matched your search
+3
-2
@@ -19,7 +19,7 @@ import (
|
|||||||
// Like cmdRestore it deliberately holds NO database credentials and never calls
|
// Like cmdRestore it deliberately holds NO database credentials and never calls
|
||||||
// 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 three 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, one environment variable. Every isolation guarantee
|
||||||
// lives in the Pod spec (internal/fileedit/jobspec.go), and the path-containment
|
// lives in the Pod spec (internal/fileedit/jobspec.go), and the path-containment
|
||||||
// guarantee lives in fileedit.Execute, which resolves the path through os.Root and
|
// guarantee lives in fileedit.Execute, which resolves the path through os.Root and
|
||||||
@@ -37,6 +37,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
|||||||
op := fs.String("op", "", "operation: list, read, or write")
|
op := fs.String("op", "", "operation: list, read, or write")
|
||||||
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")
|
||||||
if err := fs.Parse(args); err != nil {
|
if err := fs.Parse(args); err != nil {
|
||||||
return 2
|
return 2
|
||||||
}
|
}
|
||||||
@@ -67,7 +68,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
|
|||||||
content = decoded
|
content = decoded
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := fileedit.Execute(*worldsRoot, *op, *path, content)
|
res, err := fileedit.Execute(*worldsRoot, *op, *path, content, *expect)
|
||||||
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)
|
||||||
|
|||||||
+26
-4
@@ -1305,7 +1305,7 @@ 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: Server is not stopped (not_stopped), a restore, backup or file write already holds its world volume (maintenance_in_progress), or the file changed since expect_sha256 was read (file_changed).
|
||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
@@ -3244,10 +3244,16 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [path, content]
|
required: [path, content, sha256]
|
||||||
properties:
|
properties:
|
||||||
path: { type: string }
|
path: { type: string }
|
||||||
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
||||||
|
sha256:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{64}$'
|
||||||
|
description: >-
|
||||||
|
SHA-256 of the file as stored (before the rcon.password redaction in
|
||||||
|
server.properties). Send it back as expect_sha256 on the next write.
|
||||||
'400':
|
'400':
|
||||||
description: Missing path, invalid server name, or a path that escapes the world root.
|
description: Missing path, invalid server name, or a path that escapes the world root.
|
||||||
content:
|
content:
|
||||||
@@ -3289,7 +3295,10 @@ paths:
|
|||||||
survive intact. Writes are capped at 256 KiB — the Job spec carries the content,
|
survive intact. Writes are capped at 256 KiB — the Job spec carries the content,
|
||||||
and etcd bounds the object — so a larger body is 413. Same stopped-gate and
|
and etcd bounds the object — so a larger body is 413. Same stopped-gate and
|
||||||
os.Root containment as the read; a write through a symlink leaving the world
|
os.Root containment as the read; a write through a symlink leaving the world
|
||||||
root is refused. Audited as file.write.
|
root is refused. The replacement is atomic (a synced temporary sibling renamed
|
||||||
|
over the file, keeping its mode), so a failed write leaves the old file whole.
|
||||||
|
With expect_sha256 the write lands only if the file still has that hash;
|
||||||
|
otherwise 409 file_changed. Audited as file.write.
|
||||||
x-felis-face: [external]
|
x-felis-face: [external]
|
||||||
x-felis-tier: app
|
x-felis-tier: app
|
||||||
security: [{ accessJWT: [] }]
|
security: [{ accessJWT: [] }]
|
||||||
@@ -3309,6 +3318,13 @@ paths:
|
|||||||
required: [content]
|
required: [content]
|
||||||
properties:
|
properties:
|
||||||
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
content: { type: string, format: byte, description: Base64-encoded file bytes. }
|
||||||
|
expect_sha256:
|
||||||
|
type: string
|
||||||
|
pattern: '^[0-9a-f]{64}$'
|
||||||
|
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.
|
||||||
responses:
|
responses:
|
||||||
'200':
|
'200':
|
||||||
description: File written.
|
description: File written.
|
||||||
@@ -3316,10 +3332,11 @@ paths:
|
|||||||
application/json:
|
application/json:
|
||||||
schema:
|
schema:
|
||||||
type: object
|
type: object
|
||||||
required: [path, status]
|
required: [path, status, sha256]
|
||||||
properties:
|
properties:
|
||||||
path: { type: string }
|
path: { type: string }
|
||||||
status: { type: string, const: written }
|
status: { type: string, const: written }
|
||||||
|
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes written. }
|
||||||
'400':
|
'400':
|
||||||
description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
|
description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
|
||||||
content:
|
content:
|
||||||
@@ -3344,6 +3361,11 @@ paths:
|
|||||||
content:
|
content:
|
||||||
application/json:
|
application/json:
|
||||||
schema: { $ref: '#/components/schemas/Error' }
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
|
'507':
|
||||||
|
description: The world volume has no room for the write (volume_full); the file is unchanged.
|
||||||
|
content:
|
||||||
|
application/json:
|
||||||
|
schema: { $ref: '#/components/schemas/Error' }
|
||||||
'503':
|
'503':
|
||||||
$ref: '#/components/responses/ServiceUnavailable'
|
$ref: '#/components/responses/ServiceUnavailable'
|
||||||
'504':
|
'504':
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"errors"
|
"errors"
|
||||||
"net/http"
|
"net/http"
|
||||||
|
"regexp"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
"felis.lolicon.best/internal/apis/felis/v1alpha1"
|
||||||
"felis.lolicon.best/internal/fileedit"
|
"felis.lolicon.best/internal/fileedit"
|
||||||
@@ -30,12 +31,16 @@ import (
|
|||||||
// mirrors how ImageBuilder uses build.Request/build.Image rather than restating a
|
// mirrors how ImageBuilder uses build.Request/build.Image rather than restating a
|
||||||
// parallel type on this side of the seam.
|
// parallel type on this side of the seam.
|
||||||
//
|
//
|
||||||
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge for caller-fault
|
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
|
||||||
// failures, which writeFileEditError maps to 404 / 400 / 413.
|
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507.
|
||||||
|
//
|
||||||
|
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash
|
||||||
|
// a client read the file at; when set, a file that changed since is refused with
|
||||||
|
// ErrConflict instead of being overwritten.
|
||||||
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) ([]byte, error)
|
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
|
||||||
Write(ctx context.Context, server, path string, content []byte) error
|
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err 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
|
||||||
@@ -49,8 +54,14 @@ type FileEditor interface {
|
|||||||
// answering 200 — a client serialisation bug silently destroying the very config
|
// answering 200 — a client serialisation bug silently destroying the very config
|
||||||
// the caller opened this endpoint to repair. nil now means "the field was omitted"
|
// the caller opened this endpoint to repair. nil now means "the field was omitted"
|
||||||
// and is refused; an explicit "" is still a legitimate deliberate truncate.
|
// and is refused; an explicit "" is still a legitimate deliberate truncate.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
|
// file_changed; omitting it (a script, or "overwrite anyway") writes
|
||||||
|
// unconditionally.
|
||||||
type writeFileRequest struct {
|
type writeFileRequest struct {
|
||||||
Content *[]byte `json:"content"`
|
Content *[]byte `json:"content"`
|
||||||
|
ExpectSHA256 string `json:"expect_sha256,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
|
||||||
@@ -98,12 +109,12 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := a.Files.Read(r.Context(), name, path)
|
content, sum, err := a.Files.Read(r.Context(), name, path)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
writeFileEditError(w, r, err)
|
writeFileEditError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content})
|
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's
|
// handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's
|
||||||
@@ -149,6 +160,13 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
len(*body.Content), fileedit.MaxWriteBytes))
|
len(*body.Content), fileedit.MaxWriteBytes))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
// The hash rides the Job's argv, so only its one legitimate shape is let
|
||||||
|
// through: 64 lowercase hex digits, exactly what a read returned.
|
||||||
|
if body.ExpectSHA256 != "" && !sha256Hex.MatchString(body.ExpectSHA256) {
|
||||||
|
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
|
||||||
|
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
|
||||||
|
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
|
||||||
@@ -159,15 +177,18 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
defer release()
|
defer release()
|
||||||
|
|
||||||
if err := a.Files.Write(r.Context(), name, path, *body.Content); err != nil {
|
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256)
|
||||||
|
if err != nil {
|
||||||
writeFileEditError(w, r, err)
|
writeFileEditError(w, r, err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
a.audit(r, "file.write", name+":"+path)
|
a.audit(r, "file.write", name+":"+path)
|
||||||
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written"})
|
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 all three file handlers — 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
|
||||||
@@ -260,6 +281,10 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
|
|||||||
writeError(w, r, newError(http.StatusBadRequest, "bad_path", "%s", err.Error()))
|
writeError(w, r, newError(http.StatusBadRequest, "bad_path", "%s", err.Error()))
|
||||||
case errors.Is(err, fileedit.ErrTooLarge):
|
case errors.Is(err, fileedit.ErrTooLarge):
|
||||||
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large", "%s", err.Error()))
|
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large", "%s", err.Error()))
|
||||||
|
case errors.Is(err, fileedit.ErrConflict):
|
||||||
|
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
|
||||||
|
case errors.Is(err, fileedit.ErrNoSpace):
|
||||||
|
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%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"))
|
||||||
|
|||||||
@@ -23,10 +23,12 @@ type fakeFileEditor struct {
|
|||||||
gotServer string
|
gotServer string
|
||||||
gotPath string
|
gotPath string
|
||||||
gotContent []byte
|
gotContent []byte
|
||||||
|
gotExpect string
|
||||||
|
|
||||||
entries []fileedit.Entry
|
entries []fileedit.Entry
|
||||||
truncated bool
|
truncated bool
|
||||||
content []byte
|
content []byte
|
||||||
|
sum string
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedit.Entry, bool, error) {
|
func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedit.Entry, bool, error) {
|
||||||
@@ -35,18 +37,21 @@ func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedi
|
|||||||
return f.entries, f.truncated, f.err
|
return f.entries, f.truncated, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, error) {
|
func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, string, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
f.gotServer, f.gotPath = server, path
|
f.gotServer, f.gotPath = server, path
|
||||||
return f.content, f.err
|
return f.content, f.sum, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte) error {
|
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) {
|
||||||
f.calls++
|
f.calls++
|
||||||
f.gotServer, f.gotPath, f.gotContent = server, path, content
|
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect
|
||||||
return f.err
|
return f.sum, f.err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// testSum is a well-formed sha256 hex digest for the fake to hand out.
|
||||||
|
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.
|
||||||
@@ -310,9 +315,10 @@ func TestFileEditorHandlers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("read returns base64 content", func(t *testing.T) {
|
t.Run("read returns base64 content and its hash", func(t *testing.T) {
|
||||||
api, _, _, files := mkFiles()
|
api, _, _, files := mkFiles()
|
||||||
files.content = []byte("motd=hello\n")
|
files.content = []byte("motd=hello\n")
|
||||||
|
files.sum = testSum
|
||||||
api.External = staticExternal{p: owner}
|
api.External = staticExternal{p: owner}
|
||||||
|
|
||||||
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=server.properties", "", nil)
|
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=server.properties", "", nil)
|
||||||
@@ -322,11 +328,12 @@ func TestFileEditorHandlers(t *testing.T) {
|
|||||||
var resp struct {
|
var resp struct {
|
||||||
Path string `json:"path"`
|
Path string `json:"path"`
|
||||||
Content []byte `json:"content"`
|
Content []byte `json:"content"`
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
}
|
}
|
||||||
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
|
||||||
t.Fatalf("body not JSON: %v", err)
|
t.Fatalf("body not JSON: %v", err)
|
||||||
}
|
}
|
||||||
if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" {
|
if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum {
|
||||||
t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
|
t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -361,6 +368,53 @@ func TestFileEditorHandlers(t *testing.T) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
|
||||||
|
api, _, _, files := mkFiles()
|
||||||
|
files.sum = strings.Repeat("b", 64)
|
||||||
|
api.External = staticExternal{p: owner}
|
||||||
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
||||||
|
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusOK {
|
||||||
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if files.gotExpect != testSum {
|
||||||
|
t.Fatalf("executor got expect %q, want %q", files.gotExpect, testSum)
|
||||||
|
}
|
||||||
|
var resp struct {
|
||||||
|
SHA256 string `json:"sha256"`
|
||||||
|
}
|
||||||
|
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil || resp.SHA256 != files.sum {
|
||||||
|
t.Fatalf("response sha256 = %q (%v), want %q", resp.SHA256, err, files.sum)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
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"} {
|
||||||
|
api, _, _, files := mkFiles()
|
||||||
|
api.External = staticExternal{p: owner}
|
||||||
|
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",
|
||||||
|
string(body), jsonHeader)
|
||||||
|
if w.Code != http.StatusBadRequest || files.calls != 0 {
|
||||||
|
t.Fatalf("expect %q: code = %d calls = %d, want 400 and no Job", bad, w.Code, files.calls)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
|
||||||
|
api, repo, _, files := mkFiles()
|
||||||
|
files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
|
||||||
|
api.External = staticExternal{p: owner}
|
||||||
|
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
|
||||||
|
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
|
||||||
|
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_changed" {
|
||||||
|
t.Fatalf("code = %d body %s, want 409 file_changed", w.Code, w.Body.String())
|
||||||
|
}
|
||||||
|
if len(repo.audits) != 0 {
|
||||||
|
t.Fatalf("a refused write was audited: %+v", repo.audits)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
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()
|
||||||
api.External = staticExternal{p: owner}
|
api.External = staticExternal{p: owner}
|
||||||
@@ -457,6 +511,8 @@ func TestFileEditorErrorMapping(t *testing.T) {
|
|||||||
{"escaping path", fmt.Errorf("%w: nope", fileedit.ErrBadPath), http.StatusBadRequest, "bad_path"},
|
{"escaping path", fmt.Errorf("%w: nope", fileedit.ErrBadPath), http.StatusBadRequest, "bad_path"},
|
||||||
{"missing file", fmt.Errorf("%w: nope", fileedit.ErrNotFound), http.StatusNotFound, "not_found"},
|
{"missing file", fmt.Errorf("%w: nope", fileedit.ErrNotFound), http.StatusNotFound, "not_found"},
|
||||||
{"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"},
|
||||||
|
{"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
|
||||||
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
|
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+29
-12
@@ -64,6 +64,12 @@ var (
|
|||||||
// ErrTooLarge is a read of a file over MaxReadBytes or a write over
|
// ErrTooLarge is a read of a file over MaxReadBytes or a write over
|
||||||
// MaxWriteBytes.
|
// MaxWriteBytes.
|
||||||
ErrTooLarge = errors.New("fileedit: file is too large for the editor")
|
ErrTooLarge = errors.New("fileedit: file is too large for the editor")
|
||||||
|
// ErrConflict is a write whose expected hash no longer matches: the file
|
||||||
|
// changed after the caller read it.
|
||||||
|
ErrConflict = errors.New("fileedit: file changed since it was read")
|
||||||
|
// ErrNoSpace is a write the world volume had no room for; the file is
|
||||||
|
// unchanged.
|
||||||
|
ErrNoSpace = errors.New("fileedit: the world volume is full")
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -181,7 +187,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, OpList, path, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, false, err
|
return nil, false, err
|
||||||
}
|
}
|
||||||
@@ -193,25 +199,31 @@ func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool,
|
|||||||
return res.Entries, res.Truncated, nil
|
return res.Entries, res.Truncated, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Read returns a file's bytes, resolved under the server's world root.
|
// Read returns a file's bytes, resolved under the server's world root, and the
|
||||||
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, error) {
|
// SHA-256 of the file as it is on disk — the value to hand back as Write's expect.
|
||||||
res, err := e.run(ctx, server, OpRead, path, nil)
|
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) {
|
||||||
|
res, err := e.run(ctx, server, OpRead, path, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, "", err
|
||||||
}
|
}
|
||||||
// A zero-length file unmarshals Content as nil, which is a legitimate result,
|
// A zero-length file unmarshals Content as nil, which is a legitimate result,
|
||||||
// not an error — normalise so the caller never has to distinguish nil from empty.
|
// not an error — normalise so the caller never has to distinguish nil from empty.
|
||||||
if res.Content == nil {
|
if res.Content == nil {
|
||||||
res.Content = []byte{}
|
res.Content = []byte{}
|
||||||
}
|
}
|
||||||
return res.Content, nil
|
return res.Content, res.SHA256, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Write replaces a file's contents, creating it if absent (but never creating
|
// Write atomically replaces a file's contents, creating it if absent (but never
|
||||||
// parent directories — see the write helper in exec.go).
|
// creating parent directories — see the write helper in exec.go), and returns the
|
||||||
func (e *Editor) Write(ctx context.Context, server, path string, content []byte) error {
|
// new SHA-256. A non-empty expect makes it conditional: ErrConflict if the file no
|
||||||
_, err := e.run(ctx, server, OpWrite, path, content)
|
// longer hashes to it.
|
||||||
return err
|
func (e *Editor) Write(ctx context.Context, server, path string, content []byte, expect string) (string, error) {
|
||||||
|
res, err := e.run(ctx, server, OpWrite, path, content, expect)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return res.SHA256, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// run is the shared body of all three operations: mint an op id, render the
|
// run is the shared body of all three operations: mint an op id, render the
|
||||||
@@ -221,7 +233,7 @@ func (e *Editor) Write(ctx context.Context, server, path string, content []byte)
|
|||||||
// 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) (Result, error) {
|
func (e *Editor) run(ctx context.Context, server, op, path string, content []byte, expect string) (Result, error) {
|
||||||
if op == OpWrite && len(content) > MaxWriteBytes {
|
if op == OpWrite && len(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(content), MaxWriteBytes)
|
||||||
@@ -246,6 +258,7 @@ func (e *Editor) run(ctx context.Context, server, op, path string, content []byt
|
|||||||
Op: op,
|
Op: op,
|
||||||
Path: path,
|
Path: path,
|
||||||
Content: content,
|
Content: content,
|
||||||
|
Expect: expect,
|
||||||
WorldPVC: naming.WorldPVCName(server),
|
WorldPVC: naming.WorldPVCName(server),
|
||||||
Namespace: cfg.Namespace,
|
Namespace: cfg.Namespace,
|
||||||
ServiceAccount: cfg.ServiceAccount,
|
ServiceAccount: cfg.ServiceAccount,
|
||||||
@@ -284,6 +297,10 @@ func resultError(res Result) error {
|
|||||||
return fmt.Errorf("%w: %s", ErrBadPath, res.Error)
|
return fmt.Errorf("%w: %s", ErrBadPath, res.Error)
|
||||||
case CodeTooLarge:
|
case CodeTooLarge:
|
||||||
return fmt.Errorf("%w: %s", ErrTooLarge, res.Error)
|
return fmt.Errorf("%w: %s", ErrTooLarge, res.Error)
|
||||||
|
case CodeConflict:
|
||||||
|
return fmt.Errorf("%w: %s", ErrConflict, res.Error)
|
||||||
|
case CodeNoSpace:
|
||||||
|
return fmt.Errorf("%w: %s", ErrNoSpace, 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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -63,15 +63,15 @@ func TestEditorRendersParams(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("read", func(t *testing.T) {
|
t.Run("read", func(t *testing.T) {
|
||||||
r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n")})}
|
r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n"), SHA256: "abc"})}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
|
|
||||||
got, err := e.Read(context.Background(), "survival", "server.properties")
|
got, sum, err := e.Read(context.Background(), "survival", "server.properties")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Read: %v", err)
|
t.Fatalf("Read: %v", err)
|
||||||
}
|
}
|
||||||
if string(got) != "motd=hi\n" {
|
if string(got) != "motd=hi\n" || sum != "abc" {
|
||||||
t.Fatalf("content = %q", got)
|
t.Fatalf("content = %q sha256 = %q", got, sum)
|
||||||
}
|
}
|
||||||
if r.got[0].Op != OpRead || r.got[0].Path != "server.properties" {
|
if r.got[0].Op != OpRead || r.got[0].Path != "server.properties" {
|
||||||
t.Fatalf("params = %+v", r.got[0])
|
t.Fatalf("params = %+v", r.got[0])
|
||||||
@@ -79,14 +79,15 @@ func TestEditorRendersParams(t *testing.T) {
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("write", func(t *testing.T) {
|
t.Run("write", func(t *testing.T) {
|
||||||
r := &fakeRunner{payload: mustPayload(t, Result{})}
|
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
|
|
||||||
if err := e.Write(context.Background(), "survival", "ops.json", []byte("[]")); err != nil {
|
sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old")
|
||||||
|
if err != nil {
|
||||||
t.Fatalf("Write: %v", err)
|
t.Fatalf("Write: %v", err)
|
||||||
}
|
}
|
||||||
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" {
|
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" || sum != "new" {
|
||||||
t.Fatalf("params = %+v", r.got[0])
|
t.Fatalf("params = %+v, sha256 = %q", r.got[0], sum)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -100,7 +101,7 @@ func TestEditorMintsAFreshOpID(t *testing.T) {
|
|||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
|
|
||||||
for range 3 {
|
for range 3 {
|
||||||
if _, err := e.Read(context.Background(), "survival", "x"); err != nil {
|
if _, _, err := e.Read(context.Background(), "survival", "x"); err != nil {
|
||||||
t.Fatalf("Read: %v", err)
|
t.Fatalf("Read: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -129,12 +130,14 @@ func TestEditorMapsResultCodes(t *testing.T) {
|
|||||||
{"missing file", CodeNotFound, ErrNotFound},
|
{"missing file", CodeNotFound, ErrNotFound},
|
||||||
{"escaping path", CodeBadPath, ErrBadPath},
|
{"escaping path", CodeBadPath, ErrBadPath},
|
||||||
{"oversized", CodeTooLarge, ErrTooLarge},
|
{"oversized", CodeTooLarge, ErrTooLarge},
|
||||||
|
{"changed since read", CodeConflict, ErrConflict},
|
||||||
|
{"volume full", CodeNoSpace, ErrNoSpace},
|
||||||
}
|
}
|
||||||
for _, tc := range cases {
|
for _, tc := range cases {
|
||||||
t.Run(tc.name, func(t *testing.T) {
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
r := &fakeRunner{payload: mustPayload(t, Result{Code: tc.code, Error: "detail here"})}
|
r := &fakeRunner{payload: mustPayload(t, Result{Code: tc.code, Error: "detail here"})}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
_, err := e.Read(context.Background(), "survival", "x")
|
_, _, err := e.Read(context.Background(), "survival", "x")
|
||||||
if !errors.Is(err, tc.want) {
|
if !errors.Is(err, tc.want) {
|
||||||
t.Fatalf("err = %v, want %v", err, tc.want)
|
t.Fatalf("err = %v, want %v", err, tc.want)
|
||||||
}
|
}
|
||||||
@@ -144,7 +147,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
|
|||||||
t.Run("an unknown code still fails", func(t *testing.T) {
|
t.Run("an unknown code still fails", func(t *testing.T) {
|
||||||
r := &fakeRunner{payload: mustPayload(t, Result{Code: "from_the_future", Error: "?"})}
|
r := &fakeRunner{payload: mustPayload(t, Result{Code: "from_the_future", Error: "?"})}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||||
t.Fatal("an unrecognised failure code must not read as success")
|
t.Fatal("an unrecognised failure code must not read as success")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -152,7 +155,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
|
|||||||
t.Run("a malformed payload is an error, not an empty success", func(t *testing.T) {
|
t.Run("a malformed payload is an error, not an empty success", func(t *testing.T) {
|
||||||
r := &fakeRunner{payload: []byte("not json at all")}
|
r := &fakeRunner{payload: []byte("not json at all")}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||||
t.Fatal("a malformed result must fail")
|
t.Fatal("a malformed result must fail")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -160,7 +163,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
|
|||||||
t.Run("a runner failure propagates", func(t *testing.T) {
|
t.Run("a runner failure propagates", func(t *testing.T) {
|
||||||
r := &fakeRunner{err: errors.New("pod never scheduled")}
|
r := &fakeRunner{err: errors.New("pod never scheduled")}
|
||||||
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
e := &Editor{Runner: r, Config: Config{Image: "img"}}
|
||||||
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
|
||||||
t.Fatal("a runner error must propagate")
|
t.Fatal("a runner error must propagate")
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -173,7 +176,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), "")
|
||||||
if !errors.Is(err, ErrTooLarge) {
|
if !errors.Is(err, ErrTooLarge) {
|
||||||
t.Fatalf("err = %v, want ErrTooLarge", err)
|
t.Fatalf("err = %v, want ErrTooLarge", err)
|
||||||
}
|
}
|
||||||
@@ -197,7 +200,7 @@ func TestEditorNormalisesEmptyResults(t *testing.T) {
|
|||||||
t.Fatal("an empty directory must list as [], not nil")
|
t.Fatal("an empty directory must list as [], not nil")
|
||||||
}
|
}
|
||||||
|
|
||||||
content, err := e.Read(context.Background(), "survival", "empty.txt")
|
content, _, err := e.Read(context.Background(), "survival", "empty.txt")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Read: %v", err)
|
t.Fatalf("Read: %v", err)
|
||||||
}
|
}
|
||||||
|
|||||||
+207
-25
@@ -2,6 +2,9 @@ package fileedit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"crypto/rand"
|
||||||
|
"crypto/sha256"
|
||||||
|
"encoding/hex"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
@@ -9,6 +12,8 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
|
"strings"
|
||||||
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"felis.lolicon.best/internal/naming"
|
"felis.lolicon.best/internal/naming"
|
||||||
@@ -46,6 +51,14 @@ const (
|
|||||||
CodeBadPath = "bad_path" // escapes the world root, is absolute, or is otherwise unopenable
|
CodeBadPath = "bad_path" // escapes the world root, is absolute, or is otherwise unopenable
|
||||||
CodeNotFound = "not_found" // resolves inside the root but nothing is there
|
CodeNotFound = "not_found" // resolves inside the root but nothing is there
|
||||||
CodeTooLarge = "too_large" // the file exceeds MaxReadBytes
|
CodeTooLarge = "too_large" // the file exceeds MaxReadBytes
|
||||||
|
// CodeConflict is a write whose expected hash no longer matches the file: it
|
||||||
|
// changed (or vanished) after the caller read it, so saving would silently
|
||||||
|
// discard someone else's edit.
|
||||||
|
CodeConflict = "conflict"
|
||||||
|
// CodeNoSpace is a write the volume had no room for. The file is unchanged
|
||||||
|
// (the write is atomic), so this is the caller's volume being full rather
|
||||||
|
// than a bad request.
|
||||||
|
CodeNoSpace = "no_space"
|
||||||
)
|
)
|
||||||
|
|
||||||
// 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
|
||||||
@@ -119,6 +132,11 @@ type Result struct {
|
|||||||
// client renders "showing first N" rather than silently implying the directory
|
// client renders "showing first N" rather than silently implying the directory
|
||||||
// is smaller than it is.
|
// is smaller than it is.
|
||||||
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
|
||||||
|
// 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
|
||||||
|
// of its next write (see write).
|
||||||
|
SHA256 string `json:"sha256,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// Execute performs op on the file named by path, resolved inside root, and returns
|
// Execute performs op on the file named by path, resolved inside root, and returns
|
||||||
@@ -145,7 +163,10 @@ type Result struct {
|
|||||||
// "<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.
|
||||||
func Execute(root, op, path string, content []byte) (Result, error) {
|
//
|
||||||
|
// expect is a write's precondition: when non-empty, the write lands only if the
|
||||||
|
// file's current SHA-256 (hex) equals it. It is ignored by list and read.
|
||||||
|
func Execute(root, op, path string, content []byte, expect string) (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.
|
||||||
@@ -163,7 +184,7 @@ func Execute(root, op, path string, content []byte) (Result, error) {
|
|||||||
case OpRead:
|
case OpRead:
|
||||||
return read(r, path), nil
|
return read(r, path), nil
|
||||||
case OpWrite:
|
case OpWrite:
|
||||||
return write(r, path, content), nil
|
return write(r, path, content, expect), nil
|
||||||
default:
|
default:
|
||||||
return Result{}, fmt.Errorf("unknown op %q", op)
|
return Result{}, fmt.Errorf("unknown op %q", op)
|
||||||
}
|
}
|
||||||
@@ -268,7 +289,7 @@ func read(r *os.Root, name string) Result {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return failure(err, name)
|
return failure(err, name)
|
||||||
}
|
}
|
||||||
return Result{Content: redactSecretProps(name, b)}
|
return Result{Content: redactSecretProps(name, b), SHA256: digest(b)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// propsPath is the server's main config file, and rconPasswordKey the one line in
|
// propsPath is the server's main config file, and rconPasswordKey the one line in
|
||||||
@@ -314,16 +335,34 @@ func redactSecretProps(name string, content []byte) []byte {
|
|||||||
return bytes.Join(lines, []byte("\n"))
|
return bytes.Join(lines, []byte("\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// write replaces a file's contents. It truncates rather than appends, and it does
|
// write replaces a file's contents. It does NOT create parent directories: every
|
||||||
// NOT create parent directories: every path this editor writes is an existing
|
// path this editor writes is an existing config file being corrected, so an
|
||||||
// config file being corrected, so an unexpected mkdir would more likely be a typo
|
// unexpected mkdir would more likely be a typo materialising a stray directory in
|
||||||
// materialising a stray directory in the world mount than an intent.
|
// the world mount than an intent. A missing file is still created, so a config
|
||||||
|
// the server has not yet generated can be authored.
|
||||||
//
|
//
|
||||||
// O_CREATE is still allowed so a config file the server has not yet generated can
|
// The replacement is atomic. The bytes go to a temporary sibling that is synced
|
||||||
// be authored. os.Root applies the same containment to the create as to an open,
|
// and then renamed over the target, so a full disk, a Job killed at its deadline
|
||||||
// so a symlink at the target pointing outside the root is refused rather than
|
// or a crashed node leaves either the old file or the new one — never the
|
||||||
// followed — the classic "write through a planted symlink" escape.
|
// zero-length or half-written server.properties an in-place truncate would, which
|
||||||
func write(r *os.Root, path string, content []byte) Result {
|
// 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
|
||||||
|
// its read). A file that has changed since — another manager saved it, or the
|
||||||
|
// server rewrote it on its last run — is refused with CodeConflict instead of
|
||||||
|
// being overwritten, which is how two people editing the same file find out.
|
||||||
|
// The world lock (internal/maintenance) already serialises writes, so the check
|
||||||
|
// and the rename cannot interleave with another write.
|
||||||
|
//
|
||||||
|
// 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
|
||||||
@@ -331,29 +370,172 @@ func write(r *os.Root, path string, content []byte) 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)}
|
||||||
}
|
}
|
||||||
f, err := r.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
|
target, res := resolveLink(r, name)
|
||||||
|
if res.Code != "" {
|
||||||
|
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 res := checkUnchanged(r, name, target, expect); res.Code != "" {
|
||||||
|
return res
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var suffix [6]byte
|
||||||
|
if _, err := rand.Read(suffix[:]); err != nil {
|
||||||
|
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}
|
||||||
|
}
|
||||||
|
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)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return failure(err, path)
|
return writeFailure(err, name)
|
||||||
|
}
|
||||||
|
renamed := false
|
||||||
|
defer func() {
|
||||||
|
if !renamed {
|
||||||
|
_ = r.Remove(tmp)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
// Chmod explicitly: the create mode passed through the umask, and a file the
|
||||||
|
// owner had at 0664 or 0600 should come back the same.
|
||||||
|
if err := f.Chmod(mode); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return writeFailure(err, name)
|
||||||
}
|
}
|
||||||
if _, err := f.Write(content); err != nil {
|
if _, err := f.Write(content); err != nil {
|
||||||
f.Close()
|
f.Close()
|
||||||
return failure(err, path)
|
return writeFailure(err, name)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// surfaces, so its error is honoured rather than deferred-and-dropped.
|
||||||
|
if err := syncWritten(f); err != nil {
|
||||||
|
f.Close()
|
||||||
|
return writeFailure(err, name)
|
||||||
}
|
}
|
||||||
// Close is where a buffered-write error surfaces, so its error is honoured
|
|
||||||
// rather than deferred-and-dropped: reporting success on a write that did not
|
|
||||||
// land would leave the caller believing a broken config was fixed.
|
|
||||||
if err := f.Close(); err != nil {
|
if err := f.Close(); err != nil {
|
||||||
return failure(err, path)
|
return writeFailure(err, name)
|
||||||
|
}
|
||||||
|
// 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
|
||||||
|
// authored that Paper then fails to save. Best effort: the server's
|
||||||
|
// prepare-data initContainer re-owns anything left behind on its next start.
|
||||||
|
_ = ownWritten(r, tmp)
|
||||||
|
if err := r.Rename(tmp, target); err != nil {
|
||||||
|
return writeFailure(err, name)
|
||||||
|
}
|
||||||
|
renamed = true
|
||||||
|
// The rename lives in the directory; sync it so the new entry survives a crash
|
||||||
|
// too. Best effort: the content has landed and reporting failure would lie.
|
||||||
|
if d, err := r.Open(path.Dir(target)); err == nil {
|
||||||
|
_ = d.Sync()
|
||||||
|
d.Close()
|
||||||
|
}
|
||||||
|
return Result{SHA256: digest(content)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFailure is failure for the steps that move bytes, where a full volume is
|
||||||
|
// the likely cause and deserves its own answer: the file is still whole, and the
|
||||||
|
// fix is to free space, not to change the path.
|
||||||
|
func writeFailure(err error, name string) Result {
|
||||||
|
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) {
|
||||||
|
return Result{Code: CodeNoSpace, Error: fmt.Sprintf(
|
||||||
|
"the server's volume is full; %s was left unchanged", name)}
|
||||||
|
}
|
||||||
|
return failure(err, name)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxLinkHops bounds resolveLink, matching the kernel's own loop limit in spirit:
|
||||||
|
// a link cycle is a bad path, not a hang.
|
||||||
|
const maxLinkHops = 8
|
||||||
|
|
||||||
|
// resolveLink follows symlinks at the final component of name and returns the
|
||||||
|
// path of the file they lead to, relative to the root. An absolute link target is
|
||||||
|
// refused (os.Root would refuse it anyway); a relative one is resolved against
|
||||||
|
// the link's directory and must stay inside the root, which the next Lstat
|
||||||
|
// through os.Root enforces.
|
||||||
|
func resolveLink(r *os.Root, name string) (string, Result) {
|
||||||
|
cur := name
|
||||||
|
for range maxLinkHops {
|
||||||
|
info, err := r.Lstat(cur)
|
||||||
|
if err != nil || info.Mode()&fs.ModeSymlink == 0 {
|
||||||
|
// Missing (a new file) or not a link: the path names itself. Any other
|
||||||
|
// Lstat error surfaces from the caller's own Lstat of the same path.
|
||||||
|
return cur, Result{}
|
||||||
|
}
|
||||||
|
dest, err := r.Readlink(cur)
|
||||||
|
if err != nil {
|
||||||
|
return "", failure(err, name)
|
||||||
|
}
|
||||||
|
if path.IsAbs(dest) {
|
||||||
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
||||||
|
"%s is a symbolic link to %s, outside the server's files", name, dest)}
|
||||||
|
}
|
||||||
|
cur = path.Join(path.Dir(cur), dest)
|
||||||
|
if cur == ".." || strings.HasPrefix(cur, "../") {
|
||||||
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
|
||||||
|
"%s is a symbolic link leading outside the server's files", name)}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf("%s: too many levels of symbolic links", name)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// checkUnchanged compares the file's current digest with expect. A file larger
|
||||||
|
// than MaxReadBytes cannot be the one the caller read, so it is a conflict without
|
||||||
|
// hashing it.
|
||||||
|
func checkUnchanged(r *os.Root, name, target, expect string) Result {
|
||||||
|
conflict := func(now, why string) Result {
|
||||||
|
return Result{Code: CodeConflict, SHA256: now, Error: fmt.Sprintf(
|
||||||
|
"%s %s since it was opened; reload it, or save again to overwrite", name, why)}
|
||||||
|
}
|
||||||
|
f, err := r.Open(target)
|
||||||
|
if errors.Is(err, fs.ErrNotExist) {
|
||||||
|
return conflict("", "was deleted")
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return failure(err, name)
|
||||||
|
}
|
||||||
|
defer f.Close()
|
||||||
|
info, err := f.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return failure(err, name)
|
||||||
|
}
|
||||||
|
if info.Size() > MaxReadBytes {
|
||||||
|
return conflict("", "has changed")
|
||||||
|
}
|
||||||
|
h := sha256.New()
|
||||||
|
if _, err := io.Copy(h, io.LimitReader(f, MaxReadBytes+1)); err != nil {
|
||||||
|
return failure(err, name)
|
||||||
|
}
|
||||||
|
if now := hex.EncodeToString(h.Sum(nil)); now != expect {
|
||||||
|
return conflict(now, "has changed")
|
||||||
}
|
}
|
||||||
// The Job runs as root, so a file it just created is root's. The server runs as
|
|
||||||
// the game uid and could read it (0644) but never rewrite it — a config the
|
|
||||||
// panel authored that Paper then fails to save. Best effort: the content has
|
|
||||||
// landed and reporting failure would lie, and the server's prepare-data
|
|
||||||
// initContainer re-owns anything left behind on its next start anyway.
|
|
||||||
_ = ownWritten(r, path)
|
|
||||||
return Result{}
|
return Result{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// digest is the hex SHA-256 carried in Result.SHA256.
|
||||||
|
func digest(b []byte) string {
|
||||||
|
sum := sha256.Sum256(b)
|
||||||
|
return hex.EncodeToString(sum[:])
|
||||||
|
}
|
||||||
|
|
||||||
|
// syncWritten flushes the temporary sibling. A var so a test can fail it the way
|
||||||
|
// a full disk would.
|
||||||
|
var syncWritten = func(f *os.File) error { return f.Sync() }
|
||||||
|
|
||||||
// ownWritten hands a written file to the game uid. os.Root.Chown follows a symlink
|
// ownWritten hands a written file to the game uid. os.Root.Chown follows a symlink
|
||||||
// only within the root, so this can never re-own a file outside the mount. A var so
|
// only within the root, so this can never re-own a file outside the mount. A var so
|
||||||
// tests, which cannot chown, can observe the call.
|
// tests, which cannot chown, can observe the call.
|
||||||
|
|||||||
+153
-22
@@ -6,6 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
"syscall"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -75,7 +76,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 := Execute(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)
|
||||||
}
|
}
|
||||||
@@ -91,7 +92,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 := Execute(root, OpWrite, "planted.txt", []byte("pwned"), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -108,7 +109,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 := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -121,7 +122,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 := Execute(root, OpList, "../outside", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -138,7 +139,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 := Execute(root, OpList, "", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -161,7 +162,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 := Execute(root, OpList, "config", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -171,7 +172,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 := Execute(root, OpRead, "server.properties", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -181,7 +182,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 := Execute(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"))
|
||||||
@@ -201,13 +202,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 := Execute(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 || owned[0] != "ops.json" {
|
if len(owned) != 1 || !strings.HasPrefix(owned[0], ".ops.json.felis-edit-") {
|
||||||
t.Errorf("written file handed to the game uid = %v, want [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 := Execute(root, OpWrite, "nope/deep.txt", []byte("x"), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -217,7 +218,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 := Execute(root, OpRead, "absent.txt", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -227,7 +228,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 := Execute(root, OpRead, "config", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -237,7 +238,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 := Execute(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -250,7 +251,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 := Execute(root, OpRead, "huge.bin", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -271,7 +272,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 := Execute(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)
|
||||||
}
|
}
|
||||||
@@ -332,7 +333,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 := Execute(root, OpRead, spelling, nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: Execute: %v", spelling, err)
|
t.Fatalf("%s: Execute: %v", spelling, err)
|
||||||
}
|
}
|
||||||
@@ -347,7 +348,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 := Execute(root, OpRead, "config/paper.yml", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -357,7 +358,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 = Execute(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)
|
||||||
}
|
}
|
||||||
@@ -386,7 +387,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 := Execute(root, OpRead, "server.properties", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute: %v", err)
|
t.Fatalf("Execute: %v", err)
|
||||||
}
|
}
|
||||||
@@ -405,7 +406,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
nested, err := Execute(root, OpRead, "plugins/server.properties", nil)
|
nested, err := Execute(root, OpRead, "plugins/server.properties", nil, "")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("Execute nested: %v", err)
|
t.Fatalf("Execute nested: %v", err)
|
||||||
}
|
}
|
||||||
@@ -413,3 +414,133 @@ func TestReadRedactsRconPassword(t *testing.T) {
|
|||||||
t.Fatalf("a nested server.properties was redacted; only the world root's is the real one:\n%s", nested.Content)
|
t.Fatalf("a nested server.properties was redacted; only the world root's is the real one:\n%s", nested.Content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestWriteIsAtomic is the durability contract: a write that fails part-way leaves
|
||||||
|
// the original file byte-for-byte intact and no stray sibling behind, and a write
|
||||||
|
// that succeeds keeps the file's mode.
|
||||||
|
func TestWriteIsAtomic(t *testing.T) {
|
||||||
|
root, _ := worldRoot(t)
|
||||||
|
props := filepath.Join(root, "server.properties")
|
||||||
|
|
||||||
|
t.Run("a failed flush leaves the old file and no temporary", func(t *testing.T) {
|
||||||
|
prev := syncWritten
|
||||||
|
syncWritten = func(*os.File) error { return syscall.ENOSPC }
|
||||||
|
defer func() { syncWritten = prev }()
|
||||||
|
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=half"), "")
|
||||||
|
if err != nil || res.Code != CodeNoSpace {
|
||||||
|
t.Fatalf("Execute = %+v, %v; want no_space", res, err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(props); string(b) != "motd=hello\n" {
|
||||||
|
t.Fatalf("original became %q after a failed write", b)
|
||||||
|
}
|
||||||
|
assertNoTemporaries(t, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("the file keeps its mode", func(t *testing.T) {
|
||||||
|
if err := os.Chmod(props, 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" {
|
||||||
|
t.Fatalf("write: %v / %+v", err, res)
|
||||||
|
}
|
||||||
|
info, err := os.Stat(props)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if info.Mode().Perm() != 0o600 {
|
||||||
|
t.Fatalf("mode = %v, want 0600 kept", info.Mode().Perm())
|
||||||
|
}
|
||||||
|
assertNoTemporaries(t, root)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a link inside the root is written through, not replaced", func(t *testing.T) {
|
||||||
|
if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
if res, err := Execute(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" {
|
||||||
|
t.Fatalf("write: %v / %+v", err, res)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(filepath.Join(root, "config", "paper.yml")); string(b) != "verbose: true\n" {
|
||||||
|
t.Fatalf("link target = %q, want the new content", b)
|
||||||
|
}
|
||||||
|
if info, err := os.Lstat(filepath.Join(root, "paper-link.yml")); err != nil || info.Mode()&os.ModeSymlink == 0 {
|
||||||
|
t.Fatalf("the link itself was replaced: %v %v", info, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("a relative link climbing out of the root is refused", func(t *testing.T) {
|
||||||
|
if err := os.Symlink("../../outside/secret.txt", filepath.Join(root, "config", "climb")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
res, err := Execute(root, OpWrite, "config/climb", []byte("pwned"), "")
|
||||||
|
if err != nil || res.Code != CodeBadPath {
|
||||||
|
t.Fatalf("Execute = %+v, %v; want bad_path", res, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestWriteDetectsConcurrentChange: a save carrying the hash its read returned
|
||||||
|
// lands only while the file is still what was read.
|
||||||
|
func TestWriteDetectsConcurrentChange(t *testing.T) {
|
||||||
|
root, _ := worldRoot(t)
|
||||||
|
props := filepath.Join(root, "server.properties")
|
||||||
|
if err := os.WriteFile(props, []byte("motd=hello\nrcon.password=hunter2\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
read, err := Execute(root, OpRead, "server.properties", nil, "")
|
||||||
|
if err != nil || read.Code != "" || len(read.SHA256) != 64 {
|
||||||
|
t.Fatalf("read = %+v, %v; want content and a sha256", read, err)
|
||||||
|
}
|
||||||
|
// The hash is of the file on disk, not the redacted copy handed out, or a save
|
||||||
|
// of an unchanged server.properties would always conflict.
|
||||||
|
if bytes.Contains(read.Content, []byte("hunter2")) {
|
||||||
|
t.Fatal("rcon password was not redacted")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Someone else saves in between.
|
||||||
|
if err := os.WriteFile(props, []byte("motd=theirs\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256)
|
||||||
|
if err != nil || res.Code != CodeConflict {
|
||||||
|
t.Fatalf("stale write = %+v, %v; want a conflict", res, err)
|
||||||
|
}
|
||||||
|
if b, _ := os.ReadFile(props); string(b) != "motd=theirs\n" {
|
||||||
|
t.Fatalf("stale write overwrote the other edit: %q", b)
|
||||||
|
}
|
||||||
|
if res.SHA256 != digest([]byte("motd=theirs\n")) {
|
||||||
|
t.Fatalf("conflict sha256 = %q, want the file's current hash", res.SHA256)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
if err != nil || res.Code != "" || res.SHA256 != digest([]byte("motd=mine\n")) {
|
||||||
|
t.Fatalf("fresh write = %+v, %v", res, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// A file deleted since it was read is a conflict too, never a silent re-create.
|
||||||
|
if err := os.Remove(props); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
|
||||||
|
if err != nil || res.Code != CodeConflict {
|
||||||
|
t.Fatalf("write over a deleted file = %+v, %v; want a conflict", res, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(props); err == nil {
|
||||||
|
t.Fatal("a conditional write re-created a deleted file")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertNoTemporaries(t *testing.T, dir string) {
|
||||||
|
t.Helper()
|
||||||
|
des, err := os.ReadDir(dir)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, de := range des {
|
||||||
|
if strings.Contains(de.Name(), ".felis-edit-") {
|
||||||
|
t.Fatalf("temporary %s left behind", de.Name())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,10 +41,13 @@ type JobParams struct {
|
|||||||
Server string
|
Server string
|
||||||
// OpID is the per-invocation identifier that both names the Job and labels its
|
// OpID is the per-invocation identifier that both names the Job and labels its
|
||||||
// Pod. See Editor.run for why every invocation gets a fresh one.
|
// Pod. See Editor.run for why every invocation gets a fresh one.
|
||||||
OpID string
|
OpID string
|
||||||
Op string
|
Op string
|
||||||
Path string
|
Path string
|
||||||
Content []byte // OpWrite only
|
Content []byte // OpWrite only
|
||||||
|
// Expect is a write's precondition hash (see Execute); empty writes
|
||||||
|
// unconditionally.
|
||||||
|
Expect string
|
||||||
WorldPVC string
|
WorldPVC string
|
||||||
|
|
||||||
Namespace string
|
Namespace string
|
||||||
@@ -152,15 +155,21 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
|
|||||||
// code-review one.
|
// code-review one.
|
||||||
readOnlyWorld := p.Op != OpWrite
|
readOnlyWorld := p.Op != OpWrite
|
||||||
|
|
||||||
|
args := []string{
|
||||||
|
"--op", p.Op,
|
||||||
|
"--path", p.Path,
|
||||||
|
"--worlds-root", p.WorldsRoot,
|
||||||
|
}
|
||||||
|
// 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.
|
||||||
|
if p.Op == OpWrite && p.Expect != "" {
|
||||||
|
args = append(args, "--expect-sha256", p.Expect)
|
||||||
|
}
|
||||||
container := corev1.Container{
|
container := corev1.Container{
|
||||||
Name: containerName,
|
Name: containerName,
|
||||||
Image: p.Image,
|
Image: p.Image,
|
||||||
Command: []string{felisBinaryPath, "files"},
|
Command: []string{felisBinaryPath, "files"},
|
||||||
Args: []string{
|
Args: args,
|
||||||
"--op", p.Op,
|
|
||||||
"--path", p.Path,
|
|
||||||
"--worlds-root", p.WorldsRoot,
|
|
||||||
},
|
|
||||||
VolumeMounts: []corev1.VolumeMount{
|
VolumeMounts: []corev1.VolumeMount{
|
||||||
{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: readOnlyWorld},
|
{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: readOnlyWorld},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -148,6 +148,32 @@ func TestFilesJobIsolation(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestFilesJobExpectArg: a write's precondition hash reaches the entrypoint as a
|
||||||
|
// flag, and only a write carries one.
|
||||||
|
func TestFilesJobExpectArg(t *testing.T) {
|
||||||
|
sum := strings.Repeat("a", 64)
|
||||||
|
for _, tc := range []struct {
|
||||||
|
op string
|
||||||
|
expect string
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{OpWrite, sum, true},
|
||||||
|
{OpWrite, "", false},
|
||||||
|
{OpRead, sum, false},
|
||||||
|
} {
|
||||||
|
p := testParams(tc.op)
|
||||||
|
p.Expect = tc.expect
|
||||||
|
job, err := FilesJob(p)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("FilesJob: %v", err)
|
||||||
|
}
|
||||||
|
args := strings.Join(job.Spec.Template.Spec.Containers[0].Args, " ")
|
||||||
|
if got := strings.Contains(args, "--expect-sha256 "+sum); got != tc.want {
|
||||||
|
t.Fatalf("op %s expect %q: args %q, want flag present = %v", tc.op, tc.expect, args, tc.want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
|
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
|
||||||
// write can mutate a world. For list and read the kernel refuses the write, not
|
// write can mutate a world. For list and read 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.
|
||||||
|
|||||||
@@ -20,6 +20,8 @@
|
|||||||
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
"not_stopped": "Stop the server completely before restoring — a restore overwrites the live world volume.",
|
||||||
"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.",
|
||||||
|
"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.",
|
||||||
"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.",
|
||||||
|
|||||||
@@ -19,5 +19,11 @@
|
|||||||
"saved": "Saved {{path}}",
|
"saved": "Saved {{path}}",
|
||||||
"saving": "Saving…",
|
"saving": "Saving…",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
"too_large": "Exceeds the {{limit}} editor limit."
|
"too_large": "Exceeds the {{limit}} editor limit.",
|
||||||
|
"conflict_title": "Someone else changed this file",
|
||||||
|
"conflict_body": "The file changed after you opened it (another manager saved it, or the server rewrote it on its last run). Your edits are still in the editor and have not been written.",
|
||||||
|
"conflict_reload": "Load latest",
|
||||||
|
"conflict_reload_hint": "Discard your edits and show the file as it is now",
|
||||||
|
"conflict_overwrite": "Overwrite anyway",
|
||||||
|
"conflict_overwrite_hint": "Replace their change with your version"
|
||||||
}
|
}
|
||||||
@@ -20,6 +20,8 @@
|
|||||||
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
"not_stopped": "回档会覆盖世界的实时存储卷,请先把服务器完全停止再回档。",
|
||||||
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
|
"maintenance_in_progress": "这台服务器的世界正在回档、备份、写入文件或闲置回收——等它完成后再试。回档、备份和写文件通常一两分钟,闲置回收视世界大小可能更久。",
|
||||||
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
|
"no_world_volume": "这台服务器还没有世界卷——先启动一次让它创建,然后再试。",
|
||||||
|
"file_changed": "这个文件在你打开之后被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。为了不覆盖那次修改,这次保存没有写入。",
|
||||||
|
"volume_full": "服务器的存储卷已满,这次保存没有写入,原文件保持不变。请联系管理员扩容或清理这台服务器的数据。",
|
||||||
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
|
"backup_cooldown": "这台服务器刚备份过,手动备份之间有冷却时间——请过几分钟再试。",
|
||||||
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
|
"backup_store_full": "备份存储已满,暂时无法手动备份——请联系管理员清理空间。",
|
||||||
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
"restore_unavailable": "回档功能当前不可用,请稍后再试。",
|
||||||
|
|||||||
@@ -19,5 +19,11 @@
|
|||||||
"saved": "已保存 {{path}}",
|
"saved": "已保存 {{path}}",
|
||||||
"saving": "保存中…",
|
"saving": "保存中…",
|
||||||
"save": "保存",
|
"save": "保存",
|
||||||
"too_large": "超出编辑器 {{limit}} 上限。"
|
"too_large": "超出编辑器 {{limit}} 上限。",
|
||||||
|
"conflict_title": "文件已被其他人修改",
|
||||||
|
"conflict_body": "你打开这个文件之后,它被改过了(另一位管理者保存过,或者服务器上次运行时改写了它)。你的修改还在编辑框里,没有写入。",
|
||||||
|
"conflict_reload": "载入最新内容",
|
||||||
|
"conflict_reload_hint": "放弃你的修改,改看现在的文件",
|
||||||
|
"conflict_overwrite": "仍然覆盖",
|
||||||
|
"conflict_overwrite_hint": "用你的版本替换对方的修改"
|
||||||
}
|
}
|
||||||
@@ -714,6 +714,17 @@ describe("image whitelist and builds wire shapes", () => {
|
|||||||
expect((opts as RequestInit).method).toBe("PUT");
|
expect((opts as RequestInit).method).toBe("PUT");
|
||||||
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" }));
|
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("writeServerFile sends the hash the read returned as expect_sha256", async () => {
|
||||||
|
const fetchSpy = fakeFetch({ path: "a.txt", status: "written", sha256: "b".repeat(64) });
|
||||||
|
vi.stubGlobal("fetch", fetchSpy);
|
||||||
|
const res = await api.writeServerFile("survival", "a.txt", "aGk=", "a".repeat(64));
|
||||||
|
expect(res.sha256).toBe("b".repeat(64));
|
||||||
|
const [, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||||
|
expect((opts as RequestInit).body).toBe(
|
||||||
|
JSON.stringify({ content: "aGk=", expect_sha256: "a".repeat(64) }),
|
||||||
|
);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe("user passkey unbind wire shape", () => {
|
describe("user passkey unbind wire shape", () => {
|
||||||
|
|||||||
+19
-10
@@ -404,23 +404,25 @@ export const api = {
|
|||||||
`/servers/${name}/files?path=${encodeURIComponent(path)}`,
|
`/servers/${name}/files?path=${encodeURIComponent(path)}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
// readServerFile returns one file's bytes (base64). A file over the read
|
// readServerFile returns one file's bytes (base64) and the sha256 of the file
|
||||||
// ceiling is a 413, never a silent truncation, because a later save of a
|
// as stored. A file over the read ceiling is a 413, never a silent truncation,
|
||||||
// truncated body would destroy the rest of the file.
|
// because a later save of a truncated body would destroy the rest of the file.
|
||||||
readServerFile: (name: string, path: string) =>
|
readServerFile: (name: string, path: string) =>
|
||||||
request<{ path: string; content: string }>(
|
request<{ path: string; content: string; sha256: string }>(
|
||||||
"GET",
|
"GET",
|
||||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||||
),
|
),
|
||||||
|
|
||||||
// writeServerFile replaces a file's contents (creating it if absent). Sending
|
// writeServerFile atomically replaces a file's contents (creating it if
|
||||||
// an explicit "" is a deliberate truncate; the wire field is required, but that
|
// absent). Sending an explicit "" is a deliberate truncate; the wire field is
|
||||||
// is enforced by the caller (this method always sends one).
|
// required, but that is enforced by the caller (this method always sends one).
|
||||||
writeServerFile: (name: string, path: string, content: string) =>
|
// With expectSha256 (the hash the read returned) a file someone changed since
|
||||||
request<{ path: string; status: string }>(
|
// is refused with 409 file_changed; without it the write is unconditional.
|
||||||
|
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
|
||||||
|
request<{ path: string; status: string; sha256: string }>(
|
||||||
"PUT",
|
"PUT",
|
||||||
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
`/servers/${name}/file?path=${encodeURIComponent(path)}`,
|
||||||
{ content },
|
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
|
||||||
),
|
),
|
||||||
|
|
||||||
// Account linking (spec §10). Both are POST: start reports status from the
|
// Account linking (spec §10). Both are POST: start reports status from the
|
||||||
@@ -756,6 +758,13 @@ export function humanizeError(e: unknown): string {
|
|||||||
return t("maintenance_in_progress");
|
return t("maintenance_in_progress");
|
||||||
case "no_world_volume":
|
case "no_world_volume":
|
||||||
return t("no_world_volume");
|
return t("no_world_volume");
|
||||||
|
// File editor: the file changed after it was opened (another manager saved
|
||||||
|
// it, or the server rewrote it), so the save was refused rather than
|
||||||
|
// overwriting that edit.
|
||||||
|
case "file_changed":
|
||||||
|
return t("file_changed");
|
||||||
|
case "volume_full":
|
||||||
|
return t("volume_full");
|
||||||
// On-demand backup rationing (data-durability-9): one per server per
|
// On-demand backup rationing (data-durability-9): one per server per
|
||||||
// cooldown, none while the shared backup store is at its cap.
|
// cooldown, none while the shared backup store is at its cap.
|
||||||
case "backup_cooldown":
|
case "backup_cooldown":
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { useCallback, useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useParams } from "react-router-dom";
|
import { useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
|
AlertTriangle,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
FileText,
|
FileText,
|
||||||
@@ -139,29 +140,44 @@ export function ServerFiles() {
|
|||||||
}, [stopped, statusQ.reload]);
|
}, [stopped, statusQ.reload]);
|
||||||
|
|
||||||
// Editor state. `editable` false marks a binary file (rendered read-only).
|
// Editor state. `editable` false marks a binary file (rendered read-only).
|
||||||
|
// `sha256` is the hash the read returned: every save sends it back, so a file
|
||||||
|
// someone changed in the meantime is refused (409 file_changed) and `conflict`
|
||||||
|
// turns on instead of their edit being silently overwritten. `error` is a save
|
||||||
|
// failure, shown inside the dialog where the person is looking.
|
||||||
const [open, setOpen] = useState<{
|
const [open, setOpen] = useState<{
|
||||||
path: string;
|
path: string;
|
||||||
text: string;
|
text: string;
|
||||||
original: string;
|
original: string;
|
||||||
editable: boolean;
|
editable: boolean;
|
||||||
|
sha256: string;
|
||||||
|
conflict: boolean;
|
||||||
|
error: string | null;
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [opening, setOpening] = useState<string | null>(null);
|
const [opening, setOpening] = useState<string | null>(null);
|
||||||
const [saving, setSaving] = useState(false);
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [reloading, setReloading] = useState(false);
|
||||||
const [stopping, setStopping] = useState(false);
|
const [stopping, setStopping] = useState(false);
|
||||||
|
|
||||||
|
async function readInto(p: string) {
|
||||||
|
const r = await api.readServerFile(name, p);
|
||||||
|
const text = decodeText(base64ToBytes(r.content ?? ""));
|
||||||
|
setOpen({
|
||||||
|
path: p,
|
||||||
|
text: text ?? "",
|
||||||
|
original: text ?? "",
|
||||||
|
editable: text !== null,
|
||||||
|
sha256: r.sha256 ?? "",
|
||||||
|
conflict: false,
|
||||||
|
error: null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
async function openFile(entry: ServerFileEntry) {
|
async function openFile(entry: ServerFileEntry) {
|
||||||
const p = joinPath(dir, entry.name);
|
const p = joinPath(dir, entry.name);
|
||||||
setOpening(p);
|
setOpening(p);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
try {
|
try {
|
||||||
const r = await api.readServerFile(name, p);
|
await readInto(p);
|
||||||
const text = decodeText(base64ToBytes(r.content ?? ""));
|
|
||||||
setOpen({
|
|
||||||
path: p,
|
|
||||||
text: text ?? "",
|
|
||||||
original: text ?? "",
|
|
||||||
editable: text !== null,
|
|
||||||
});
|
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg({ kind: "error", text: humanizeError(e) });
|
setMsg({ kind: "error", text: humanizeError(e) });
|
||||||
} finally {
|
} finally {
|
||||||
@@ -169,21 +185,42 @@ export function ServerFiles() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function handleSave() {
|
// reloadOpen resolves a conflict by taking the file as it is now.
|
||||||
|
async function reloadOpen() {
|
||||||
|
if (!open || reloading) return;
|
||||||
|
setReloading(true);
|
||||||
|
try {
|
||||||
|
await readInto(open.path);
|
||||||
|
} catch (e) {
|
||||||
|
setOpen({ ...open, error: humanizeError(e) });
|
||||||
|
} finally {
|
||||||
|
setReloading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSave writes the editor's text. `overwrite` drops the precondition,
|
||||||
|
// which is what "Overwrite anyway" on a conflict means.
|
||||||
|
async function handleSave(overwrite = false) {
|
||||||
if (!open || saving) return;
|
if (!open || saving) return;
|
||||||
setSaving(true);
|
setSaving(true);
|
||||||
setMsg(null);
|
setMsg(null);
|
||||||
|
setOpen({ ...open, error: null });
|
||||||
try {
|
try {
|
||||||
await api.writeServerFile(
|
await api.writeServerFile(
|
||||||
name,
|
name,
|
||||||
open.path,
|
open.path,
|
||||||
bytesToBase64(new TextEncoder().encode(open.text)),
|
bytesToBase64(new TextEncoder().encode(open.text)),
|
||||||
|
overwrite ? undefined : open.sha256 || undefined,
|
||||||
);
|
);
|
||||||
setMsg({ kind: "success", text: t("saved", { path: open.path }) });
|
setMsg({ kind: "success", text: t("saved", { path: open.path }) });
|
||||||
setOpen(null);
|
setOpen(null);
|
||||||
void load(dir);
|
void load(dir);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setMsg({ kind: "error", text: humanizeError(e) });
|
if ((e as { code?: string }).code === "file_changed") {
|
||||||
|
setOpen({ ...open, conflict: true, error: null });
|
||||||
|
} else {
|
||||||
|
setOpen({ ...open, error: humanizeError(e) });
|
||||||
|
}
|
||||||
} finally {
|
} finally {
|
||||||
setSaving(false);
|
setSaving(false);
|
||||||
}
|
}
|
||||||
@@ -394,7 +431,7 @@ export function ServerFiles() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* File editor dialog */}
|
{/* File editor dialog */}
|
||||||
<Dialog open={open !== null} onOpenChange={(v) => !v && !saving && setOpen(null)}>
|
<Dialog open={open !== null} onOpenChange={(v) => !v && !saving && !reloading && setOpen(null)}>
|
||||||
<DialogContent className="max-w-3xl">
|
<DialogContent className="max-w-3xl">
|
||||||
<DialogHeader>
|
<DialogHeader>
|
||||||
<DialogTitle className="break-all font-mono text-sm">
|
<DialogTitle className="break-all font-mono text-sm">
|
||||||
@@ -406,6 +443,51 @@ export function ServerFiles() {
|
|||||||
</DialogHeader>
|
</DialogHeader>
|
||||||
{open && (
|
{open && (
|
||||||
<>
|
<>
|
||||||
|
{open.conflict && (
|
||||||
|
<div
|
||||||
|
role="alert"
|
||||||
|
className="grid gap-3 rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-xs"
|
||||||
|
>
|
||||||
|
<div className="flex items-start gap-2 text-amber-700 dark:text-amber-300">
|
||||||
|
<AlertTriangle className="mt-px h-4 w-4 shrink-0" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium">{t("conflict_title")}</p>
|
||||||
|
<p className="mt-0.5 text-foreground/80">{t("conflict_body")}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2 pl-6">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => void reloadOpen()}
|
||||||
|
disabled={saving || reloading}
|
||||||
|
title={t("conflict_reload_hint")}
|
||||||
|
>
|
||||||
|
{reloading ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<RefreshCw className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("conflict_reload")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => void handleSave(true)}
|
||||||
|
disabled={saving || reloading || tooLarge}
|
||||||
|
title={t("conflict_overwrite_hint")}
|
||||||
|
>
|
||||||
|
{saving ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Save className="h-4 w-4" />
|
||||||
|
)}
|
||||||
|
{t("conflict_overwrite")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{open.error && <MessageLine kind="error" message={open.error} />}
|
||||||
<textarea
|
<textarea
|
||||||
value={open.text}
|
value={open.text}
|
||||||
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
onChange={(e) => setOpen({ ...open, text: e.target.value })}
|
||||||
@@ -423,13 +505,13 @@ export function ServerFiles() {
|
|||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
onClick={() => setOpen(null)}
|
onClick={() => setOpen(null)}
|
||||||
disabled={saving}
|
disabled={saving || reloading}
|
||||||
>
|
>
|
||||||
{t("common:cancel")}
|
{t("common:cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleSave}
|
onClick={() => void handleSave()}
|
||||||
disabled={saving || !open.editable || !dirty || tooLarge}
|
disabled={saving || reloading || open.conflict || !open.editable || !dirty || tooLarge}
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<Loader2 className="h-4 w-4 animate-spin" />
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
|||||||
Reference in new issue
Block a user