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

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

No files matched your search

+25 -9
View File
@@ -24,6 +24,7 @@ import (
"time"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
)
// API holds the dependencies shared by every handler.
@@ -84,13 +85,18 @@ type API struct {
// something has to start the restore once the snapshot is done.
RestoreChains RestoreChains
// Files is the server file editor (list / read / write a file in a stopped
// server's world volume — the "one wrong line in server.properties" repair).
// Files is the server file manager (list, read, write, make a folder, delete,
// rename and upload inside a stopped server's world volume).
// Like Restorer and Backuper it is optional: when nil the file routes report
// 503, so the owner-or-admin and stopped gates are exercised before the
// file-Job executor is wired. Unlike them its calls are synchronous, because
// the caller wants the listing or the bytes back, not a 202.
Files FileEditor
// FileStage holds uploads until the Job landing them fetches them, and
// InternalBaseURL is where that Job reaches felis-api's internal face to do so
// (handleUploadFile). Uploads report 503 unless both are set.
FileStage *fileedit.Stage
InternalBaseURL string
// Submissions is the user-modpack approval lane (a user-directed extension over
// the §16 build subsystem; see internal/submit). It is optional: when
@@ -443,6 +449,11 @@ func (a *API) internalAPIRoutes() []apiRoute {
// snapshot a stopped world while the API is alive. Service-token auth (no
// Principal); the shared enqueueBackup tail enforces the RWO stopped-gate.
{Method: "POST", Pattern: "/api/v1/internal/servers/{name}/backup", Callers: ops, h: a.handleInternalBackup},
// A file upload's staged bytes, fetched once by the Job landing them. Public
// because that Job holds no service token; the one-time bearer token minted
// with the upload is the check (handlers_files.go).
{Method: "GET", Pattern: "/api/v1/internal/file-uploads/{id}", Public: true, h: a.handleInternalFileUpload},
}
}
@@ -542,13 +553,14 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/jobs", h: a.handleServerJobs},
{Method: "POST", Pattern: "/api/v1/servers/{name}/restore-backup", h: a.handleRestoreBackup},
{Method: "POST", Pattern: "/api/v1/servers/{name}/backup", h: a.handleBackupNow},
// Server file editor: list / read / write a file in a STOPPED server's world
// volume (handlers_files.go). App-tier, exactly like the backup pair above and
// for the same reason — every route gates on owner-or-admin inside the handler,
// so an owner repairs their own broken server without an admin's Zero-Trust
// path. The path travels as ?path= rather than a segment because a file path
// contains '/' (the same reason DELETE /images takes ?ref=). {name}/files is
// the directory face; {name}/file is the single-file face.
// Server file manager: list, read, write, make a folder, delete, rename and
// upload in a STOPPED server's world volume (handlers_files.go). App-tier,
// exactly like the backup pair above and for the same reason — every route
// gates on owner-or-admin inside the handler, so an owner repairs their own
// broken server without an admin's Zero-Trust path. The path travels as ?path=
// rather than a segment because a file path contains '/' (the same reason
// DELETE /images takes ?ref=). {name}/files is the directory face; {name}/file
// is the single-file face.
//
// "Config editor" undersells the surface, so be precise about what app-tier
// now reaches: the mount is the server's WHOLE working directory, not a
@@ -560,6 +572,10 @@ func (a *API) externalAPIRoutes() []apiRoute {
{Method: "GET", Pattern: "/api/v1/servers/{name}/files", h: a.handleListFiles},
{Method: "GET", Pattern: "/api/v1/servers/{name}/file", h: a.handleReadFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/file", h: a.handleWriteFile},
{Method: "DELETE", Pattern: "/api/v1/servers/{name}/file", h: a.handleDeleteFile},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/mkdir", h: a.handleMkdir},
{Method: "POST", Pattern: "/api/v1/servers/{name}/files/rename", h: a.handleRenameFile},
{Method: "PUT", Pattern: "/api/v1/servers/{name}/files/upload", h: a.handleUploadFile},
// Account linking (spec §10), web side: /start reports link status (it is the
// pointer handleClaim's 412 emits), /verify consumes the in-game code and binds
// the account. App-tier, not admin — linking your own account is an ordinary
+266 -19
View File
@@ -3,8 +3,11 @@ package api
import (
"context"
"errors"
"io"
"net/http"
"regexp"
"strconv"
"strings"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
@@ -32,15 +35,22 @@ import (
// parallel type on this side of the seam.
//
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507.
// ErrNoSpace / ErrExists, which writeFileEditError maps to 404 / 400 / 413 / 409 /
// 507 / 409.
//
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash
// a client read the file at; when set, a file that changed since is refused with
// ErrConflict instead of being overwritten.
// Read and Write return the file's SHA-256 (hex); Upload lands exactly the bytes
// src describes or fails. 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. createOnly and a false overwrite
// refuse an existing path with ErrExists.
type FileEditor interface {
List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error)
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (sha256 string, err error)
Mkdir(ctx context.Context, server, path string) error
Delete(ctx context.Context, server, path string) error
Rename(ctx context.Context, server, path, to string) error
Upload(ctx context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error
}
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
@@ -58,10 +68,14 @@ type FileEditor interface {
// 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.
// unconditionally. CreateOnly is the panel's "new file": the write lands only if
// nothing is at the path yet (409 file_exists otherwise), so it can never
// truncate a file the caller did not know was there. The two cannot be combined —
// one says the file exists, the other that it must not.
type writeFileRequest struct {
Content *[]byte `json:"content"`
ExpectSHA256 string `json:"expect_sha256,omitempty"`
CreateOnly bool `json:"create_only,omitempty"`
}
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
@@ -105,10 +119,8 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
path, ok := requirePath(w, r)
if !ok {
return
}
@@ -140,10 +152,8 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
path, ok := requirePath(w, r)
if !ok {
return
}
@@ -173,6 +183,11 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
return
}
if body.CreateOnly && body.ExpectSHA256 != "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"create_only and expect_sha256 cannot be combined"))
return
}
// A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server
@@ -183,19 +198,249 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
}
defer release()
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256)
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256, body.CreateOnly)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.audit(r, "file.write", name+":"+path)
a.auditFile(r, "file.write", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
}
// requirePath reads the required ?path= query parameter, answering 400 when it
// is absent.
func requirePath(w http.ResponseWriter, r *http.Request) (string, bool) {
path := r.URL.Query().Get("path")
if path == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"the ?path= query parameter is required"))
return "", false
}
return path, true
}
// handleMkdir serves POST /api/v1/servers/{name}/files/mkdir?path=… — make one
// folder. Its parent must exist (404 otherwise) and nothing may be at the path yet
// (409 file_exists). Like every file change it holds the world lock and is
// audited.
func (a *API) handleMkdir(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Mkdir(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.mkdir", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "created"})
}
// handleDeleteFile serves DELETE /api/v1/servers/{name}/file?path=… — delete a
// file, a symlink (never what it points at), or a folder with everything in it.
// The world root itself is refused (400 bad_path). The panel confirms first; this
// route does not, because a script that says DELETE means it.
func (a *API) handleDeleteFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Delete(r.Context(), name, path); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.delete", name, path, nil)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "deleted"})
}
// renameFileRequest is the POST /servers/{name}/files/rename body: the new path,
// relative to the world root like ?path=.
type renameFileRequest struct {
To string `json:"to"`
}
// handleRenameFile serves POST /api/v1/servers/{name}/files/rename?path=… — move
// a file or folder to body.to. It never replaces: an existing destination is 409
// file_exists. server.properties, config/paper-global.yml and config/ cannot be
// moved (400 bad_path), since under another name the read path would no longer
// know to withhold their secrets.
func (a *API) handleRenameFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
var body renameFileRequest
if err := decodeJSON(w, r, &body); err != nil {
writeError(w, r, err)
return
}
if body.To == "" {
writeError(w, r, newError(http.StatusBadRequest, "bad_request", "the to field is required"))
return
}
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
if err := a.Files.Rename(r.Context(), name, path, body.To); err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.rename", name, path, map[string]any{"to": body.To})
writeJSON(w, http.StatusOK, map[string]any{"path": path, "to": body.To, "status": "renamed"})
}
// handleUploadFile serves PUT /api/v1/servers/{name}/files/upload?path=… — land
// the raw request body as a file, up to fileedit.MaxUploadBytes. An existing file
// is 409 file_exists unless ?overwrite=true.
//
// The body is staged on felis-api's disk first (fileedit.Stage) and fetched from
// there by the Job, on the internal face, with a one-time token: it fits in
// neither a Job spec nor an environment. The world lock is taken only once the
// body has arrived, so a slow upload does not hold off a backup; the stopped gate
// ran before the body was read and the lock re-checks that nothing started since.
//
// Content-Length is required (411 length_required): the stage reserves room for
// the declared size before a byte is written, and a size promised up front is
// what lets a short body be told from a whole one.
func (a *API) handleUploadFile(w http.ResponseWriter, r *http.Request) {
name, ok := a.authorizeFileOp(w, r)
if !ok {
return
}
path, ok := requirePath(w, r)
if !ok {
return
}
if a.FileStage == nil || a.InternalBaseURL == "" {
writeError(w, r, newError(http.StatusServiceUnavailable, "files_unavailable",
"uploads are not configured"))
return
}
if r.ContentLength < 0 {
writeError(w, r, newError(http.StatusLengthRequired, "length_required",
"an upload needs a Content-Length"))
return
}
if r.ContentLength > fileedit.MaxUploadBytes {
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large",
"the file is %d bytes; uploads are at most %d", r.ContentLength, fileedit.MaxUploadBytes))
return
}
overwrite := r.URL.Query().Get("overwrite") == "true"
staged, drop, err := a.FileStage.Put(r.Body, r.ContentLength)
switch {
case errors.Is(err, fileedit.ErrStageFull):
writeError(w, r, newError(http.StatusInsufficientStorage, "upload_staging_full",
"felis has no room to take this upload right now; try again later or ask an admin"))
return
case errors.Is(err, fileedit.ErrShortUpload):
writeError(w, r, newError(http.StatusBadRequest, "upload_incomplete",
"the upload ended before all %d bytes arrived", r.ContentLength))
return
case err != nil:
writeError(w, r, err)
return
}
defer drop()
release, ok := a.acquireWorld(w, r, name, maintenance.KindFileWrite, "stop the server before editing its files")
if !ok {
return
}
defer release()
err = a.Files.Upload(r.Context(), name, path, fileedit.UploadSource{
URL: a.InternalBaseURL + "/api/v1/internal/file-uploads/" + staged.ID,
Token: staged.Token,
Size: staged.Size,
SHA256: staged.SHA256,
}, overwrite)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.auditFile(r, "file.upload", name, path, map[string]any{
"size_bytes": staged.Size, "sha256": staged.SHA256, "overwrite": overwrite,
})
writeJSON(w, http.StatusOK, map[string]any{
"path": path, "status": "uploaded", "sha256": staged.SHA256, "size": staged.Size,
})
}
// handleInternalFileUpload serves GET /api/v1/internal/file-uploads/{id} — the
// staged bytes of one upload, to the one Job created to land them. It is Public on
// the internal face: the Job holds no service token (it holds no credential at
// all), so the bearer token minted with the upload is the whole check, and it
// opens that upload once. An unknown id, a wrong token and a spent one are the
// same 404, so the route answers nothing about which uploads exist.
func (a *API) handleInternalFileUpload(w http.ResponseWriter, r *http.Request) {
token, ok := strings.CutPrefix(r.Header.Get("Authorization"), "Bearer ")
if a.FileStage == nil || !ok {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
f, size, err := a.FileStage.Open(r.PathValue("id"), token)
if errors.Is(err, fileedit.ErrNotStaged) {
writeError(w, r, newError(http.StatusNotFound, "not_found", "no such upload"))
return
}
if err != nil {
writeError(w, r, err)
return
}
defer f.Close()
w.Header().Set("Content-Type", "application/octet-stream")
w.Header().Set("Content-Length", strconv.FormatInt(size, 10))
w.WriteHeader(http.StatusOK)
_, _ = io.Copy(w, f)
}
// auditFile records a file change. The target is "<server>:<path>", as file.write
// has always recorded it; extra, when set, is the payload.
func (a *API) auditFile(r *http.Request, action, server, path string, extra map[string]any) {
p := principalFromContext(r.Context())
e := AuditEntry{Actor: auditActor(p), Action: action, ServerName: server + ":" + path}
if p != nil {
e.ActorUserID = p.UserID
}
if extra != nil {
e.Payload = auditPayload(extra)
}
a.auditEntry(r, e)
}
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// authorizeFileOp is the shared front half of all three file handlers — the gate
// authorizeFileOp is the shared front half of every file handler — the gate
// that decides whether this caller may touch this server's world at all. It
// mirrors the backup/restore gate step for step, because it is guarding the same
// resource under the same physical constraint:
@@ -211,7 +456,7 @@ var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// silently fails to mount
// ⑤ the FileEditor must be wired, else 503
//
// Single-sourcing it is what keeps the three faces from drifting: a read path that
// Single-sourcing it is what keeps the handlers from drifting: a read path that
// forgot the stopped gate would not merely fail, it would hang waiting for a Pod
// that can never be scheduled.
//
@@ -270,7 +515,7 @@ func (a *API) authorizeFileOp(w http.ResponseWriter, r *http.Request) (string, b
return name, true
}
// writeFileEditError maps executor errors onto HTTP status codes. The three
// writeFileEditError maps executor errors onto HTTP status codes. The
// sentinels are caller-fault and get precise answers; a timeout is reported as 504
// so the caller knows to retry rather than believing the edit was rejected; and
// anything else collapses to a 500 by writeError, so no cluster detail leaks.
@@ -291,6 +536,8 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
case errors.Is(err, fileedit.ErrNoSpace):
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, fileedit.ErrExists):
writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error()))
case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly"))
+474 -60
View File
@@ -2,14 +2,21 @@ package api
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strconv"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
)
// fakeFileEditor records what the handlers ask the executor to do and returns
@@ -19,11 +26,19 @@ import (
type fakeFileEditor struct {
err error
calls int
gotServer string
gotPath string
gotContent []byte
gotExpect string
calls int
gotOp string
gotServer string
gotPath string
gotContent []byte
gotExpect string
gotCreateOnly bool
gotTo string
gotSource fileedit.UploadSource
gotOverwrite bool
// onUpload, when set, runs inside Upload the way the real Job fetches the
// staged bytes while the handler waits.
onUpload func(fileedit.UploadSource)
entries []fileedit.Entry
truncated bool
@@ -43,19 +58,64 @@ func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, s
return f.content, f.sum, f.err
}
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) {
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string, createOnly bool) (string, error) {
f.calls++
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect
f.gotOp = fileedit.OpWrite
f.gotServer, f.gotPath, f.gotContent, f.gotExpect, f.gotCreateOnly = server, path, content, expect, createOnly
return f.sum, f.err
}
func (f *fakeFileEditor) Mkdir(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpMkdir, server, path
return f.err
}
func (f *fakeFileEditor) Delete(_ context.Context, server, path string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath = fileedit.OpDelete, server, path
return f.err
}
func (f *fakeFileEditor) Rename(_ context.Context, server, path, to string) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotTo = fileedit.OpRename, server, path, to
return f.err
}
func (f *fakeFileEditor) Upload(_ context.Context, server, path string, src fileedit.UploadSource, overwrite bool) error {
f.calls++
f.gotOp, f.gotServer, f.gotPath, f.gotSource, f.gotOverwrite = fileedit.OpUpload, server, path, src, overwrite
if f.onUpload != nil {
f.onUpload(src)
}
return f.err
}
// fileRouteHeader is the Content-Type a file route's body goes with: raw bytes
// for an upload, JSON for any other body.
func fileRouteHeader(name, body string) map[string]string {
switch {
case name == "upload":
return ctHeader("application/octet-stream")
case body != "":
return jsonHeader
}
return nil
}
// testSum is a well-formed sha256 hex digest for the fake to hand out.
var testSum = strings.Repeat("a", 64)
// 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
// permitted, so each subtest changes exactly the one thing it is about.
func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
//
// Uploads stage in a per-test directory. MinFree is near zero because the
// staging floor is fileedit's to test, and the machine running the tests may
// well have less than 10% of its disk free.
func mkFiles(t *testing.T) (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
t.Helper()
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
cl := newFakeCluster()
@@ -64,13 +124,15 @@ func mkFiles() (*API, *fakeRepo, *fakeCluster, *fakeFileEditor) {
files := &fakeFileEditor{}
api := newTestAPI(repo, cl)
api.Files = files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
return api, repo, cl, files
}
// TestFileEditorStoppedGate is the gate this whole subsystem hinges on. The world
// PVC is ReadWriteOnce, but RWO is per node: on a single node a file Job mounts it
// right beside a running server, and a write lands under a live world that the
// server's next save overwrites or tears. Every one of the three routes
// server's next save overwrites or tears. Every route
// must therefore refuse a non-stopped server with 409 not_stopped BEFORE reaching
// the executor, which is why each asserts calls == 0 as well as the status.
func TestFileEditorStoppedGate(t *testing.T) {
@@ -85,6 +147,10 @@ func TestFileEditorStoppedGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
// Both non-stopped shapes matter and they are different states: a server that is
@@ -103,16 +169,12 @@ func TestFileEditorStoppedGate(t *testing.T) {
for _, rt := range routes {
for _, st := range states {
t.Run(fmt.Sprintf("%s on a %s server -> 409 not_stopped", rt.name, st.name), func(t *testing.T) {
api, _, cl, files := mkFiles()
api, _, cl, files := mkFiles(t)
cl.byName["survival"].Ready = st.ready
cl.byName["survival"].DesiredState = string(st.desiredState)
api.External = staticExternal{p: owner}
var hdr map[string]string
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusConflict || decodeErr(t, w) != "not_stopped" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -128,7 +190,7 @@ func TestFileEditorStoppedGate(t *testing.T) {
// world PVC (never started, or already reaped) has no claim for the Job to mount,
// so its Pod would sit Pending until the executor's wait timed out — a 90s hang
// and a misleading 504 files_timeout for a request that is knowably impossible.
// All three routes must refuse BEFORE creating a Job, with the same specific 409
// Every route must refuse BEFORE creating a Job, with the same specific 409
// the backup/restore faces use.
func TestFileEditorWorldVolumeGate(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -142,19 +204,19 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
for _, rt := range routes {
t.Run(rt.name+" without a world volume -> 409 no_world_volume", func(t *testing.T) {
api, _, cl, files := mkFiles()
api, _, cl, files := mkFiles(t)
cl.noWorld["survival"] = true
api.External = staticExternal{p: owner}
var hdr map[string]string
if rt.body != "" {
hdr = jsonHeader
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusConflict || decodeErr(t, w) != "no_world_volume" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -166,8 +228,8 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
}
// TestFileEditorAuthorization pins who may touch a world's files. It is the same
// owner-or-admin rule the backup routes enforce, and it must hold on all three
// routes — a read-only route leaking another owner's config (an RCON password
// owner-or-admin rule the backup routes enforce, and it must hold on every
// route — a read-only route leaking another owner's config (an RCON password
// lives in server.properties) would be as bad as an unauthorized write.
func TestFileEditorAuthorization(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
@@ -183,20 +245,17 @@ func TestFileEditorAuthorization(t *testing.T) {
{"list", "GET", "/api/v1/servers/survival/files", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
}
hdrFor := func(body string) map[string]string {
if body != "" {
return jsonHeader
}
return nil
{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
{"upload", "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", "PK-jar-bytes"},
}
for _, rt := range routes {
t.Run(rt.name+": non-owner -> 403, executor untouched", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: stranger}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
@@ -206,9 +265,9 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": owner -> allowed", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -218,10 +277,10 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": admin on someone else's server -> allowed", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
repo.byName["survival"].OwnerID = "someone-else"
api.External = staticExternal{p: admin}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
}
@@ -231,40 +290,40 @@ func TestFileEditorAuthorization(t *testing.T) {
})
t.Run(rt.name+": unowned server -> 403 for a plain user", func(t *testing.T) {
api, repo, _, _ := mkFiles()
api, repo, _, _ := mkFiles(t)
repo.byName["survival"].OwnerID = "" // released world
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusForbidden {
t.Fatalf("code = %d, want 403 (%s)", w.Code, w.Body.String())
}
})
t.Run(rt.name+": unknown server -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "missing", 1), rt.body, hdrFor(rt.body))
strings.Replace(rt.path, "survival", "missing", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusNotFound {
t.Fatalf("code = %d, want 404 (%s)", w.Code, w.Body.String())
}
})
t.Run(rt.name+": invalid server name -> 400 bad_name", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method,
strings.Replace(rt.path, "survival", "X", 1), rt.body, hdrFor(rt.body))
strings.Replace(rt.path, "survival", "X", 1), rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_name" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
})
t.Run(rt.name+": nil FileEditor -> 503 files_unavailable", func(t *testing.T) {
api, _, _, _ := mkFiles()
api, _, _, _ := mkFiles(t)
api.Files = nil
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdrFor(rt.body))
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, fileRouteHeader(rt.name, rt.body))
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
}
@@ -278,7 +337,7 @@ func TestFileEditorHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("list passes the path through and returns entries", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.entries = []fileedit.Entry{{Name: "paper.yml", Size: 12}, {Name: "sub", IsDir: true}}
files.truncated = true
api.External = staticExternal{p: owner}
@@ -304,7 +363,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("list without a path lists the world root", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
if w.Code != http.StatusOK {
@@ -316,7 +375,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("read returns base64 content and its hash", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.content = []byte("motd=hello\n")
files.sum = testSum
api.External = staticExternal{p: owner}
@@ -339,7 +398,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("read without a path -> 400", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file", "", nil)
if w.Code != http.StatusBadRequest {
@@ -351,7 +410,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("write decodes content, audits, and answers 200", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -369,7 +428,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.sum = strings.Repeat("b", 64)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -390,7 +449,7 @@ func TestFileEditorHandlers(t *testing.T) {
t.Run("a malformed expected hash -> 400 before the executor", func(t *testing.T) {
for _, bad := range []string{"abc", strings.Repeat("A", 64), strings.Repeat("a", 63) + " ", "--op=list"} {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
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",
@@ -402,7 +461,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
api, repo, _, files := mkFiles()
api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
@@ -416,7 +475,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("reads are not audited", func(t *testing.T) {
api, repo, _, _ := mkFiles()
api, repo, _, _ := mkFiles(t)
api.External = staticExternal{p: owner}
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/files", "", nil)
do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -426,7 +485,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("oversized write -> 413 before the executor", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
// base64 of MaxWriteBytes+1 zero bytes, built as a JSON body.
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes+1))})
@@ -450,7 +509,7 @@ func TestFileEditorHandlers(t *testing.T) {
// because a deliberate truncate is a real edit; only the OMISSION is refused.
t.Run("a write with no content field -> 400, never a truncate", func(t *testing.T) {
for _, body := range []string{`{}`, `{"content":null}`} {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
body, jsonHeader)
@@ -464,7 +523,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("an explicit empty content is a legitimate truncate", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":""}`, jsonHeader)
@@ -478,7 +537,7 @@ func TestFileEditorHandlers(t *testing.T) {
})
t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))})
if err != nil {
@@ -495,6 +554,360 @@ func TestFileEditorHandlers(t *testing.T) {
})
}
// fileAnswer decodes a file route's JSON answer for an exact comparison.
func fileAnswer(t *testing.T, w *httptest.ResponseRecorder) map[string]any {
t.Helper()
var m map[string]any
if err := json.Unmarshal(w.Body.Bytes(), &m); err != nil {
t.Fatalf("body not JSON: %v (%s)", err, w.Body.String())
}
return m
}
// onlyAudit asserts the request wrote exactly one audit row, by owner1, with
// this action, target and payload ("" for none).
func onlyAudit(t *testing.T, repo *fakeRepo, action, target, payload string) {
t.Helper()
if len(repo.audits) != 1 {
t.Fatalf("audits = %+v, want exactly one %s", repo.audits, action)
}
a := repo.audits[0]
if a.Action != action || a.ServerName != target || a.Actor != "[email protected]" ||
a.ActorUserID != "owner1" || string(a.Payload) != payload {
t.Fatalf("audit = %+v (payload %s), want %s on %s with payload %q", a, a.Payload, action, target, payload)
}
}
// TestFileManagerHandlers covers the routes that change a world's file tree
// beyond a save: what each hands the executor, what it answers, what it audits.
func TestFileManagerHandlers(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
t.Run("mkdir makes the folder and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/mkdir?path=plugins/Essentials", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpMkdir || files.gotPath != "plugins/Essentials" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/Essentials", "status": "created"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.mkdir", "survival:plugins/Essentials", "")
})
t.Run("delete removes the path and audits it", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "DELETE", "/api/v1/servers/survival/file?path=plugins/old.jar", "", nil)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpDelete || files.gotPath != "plugins/old.jar" {
t.Fatalf("executor saw %d calls, op %q, path %q", files.calls, files.gotOp, files.gotPath)
}
if got, want := fileAnswer(t, w), (map[string]any{"path": "plugins/old.jar", "status": "deleted"}); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.delete", "survival:plugins/old.jar", "")
})
t.Run("rename passes the destination and audits both names", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=plugins/a.jar",
`{"to":"plugins/disabled/a.jar"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.calls != 1 || files.gotOp != fileedit.OpRename || files.gotPath != "plugins/a.jar" || files.gotTo != "plugins/disabled/a.jar" {
t.Fatalf("executor saw %d calls, op %q, %q -> %q", files.calls, files.gotOp, files.gotPath, files.gotTo)
}
want := map[string]any{"path": "plugins/a.jar", "to": "plugins/disabled/a.jar", "status": "renamed"}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.rename", "survival:plugins/a.jar", `{"to":"plugins/disabled/a.jar"}`)
})
t.Run("rename without a destination -> 400 before the executor", func(t *testing.T) {
for _, body := range []string{`{}`, `{"to":""}`} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "POST", "/api/v1/servers/survival/files/rename?path=a.txt", body, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("body %s: code = %d calls = %d (%s), want 400 and no Job", body, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a route that changes a path needs the path", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir", ""},
{"DELETE", "/api/v1/servers/survival/file", ""},
{"POST", "/api/v1/servers/survival/files/rename", `{"to":"b.txt"}`},
{"PUT", "/api/v1/servers/survival/files/upload", "bytes"},
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
hdr := jsonHeader
if rt.method == "PUT" {
hdr = ctHeader("application/octet-stream")
}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, hdr)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("%s %s: code = %d calls = %d (%s), want 400 and no Job", rt.method, rt.path, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("a refused change is not audited", func(t *testing.T) {
for _, rt := range []struct{ method, path, body string }{
{"POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"DELETE", "/api/v1/servers/survival/file?path=plugins", ""},
{"POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
} {
api, repo, _, files := mkFiles(t)
files.err = fmt.Errorf("%w: plugins already exists", fileedit.ErrExists)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), rt.method, rt.path, rt.body, jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("%s %s: code = %d (%s), want 409 file_exists", rt.method, rt.path, w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("%s %s: a refused change was audited: %+v", rt.method, rt.path, repo.audits)
}
}
})
t.Run("create_only reaches the executor", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml",
`{"content":"","create_only":true}`, jsonHeader)
if w.Code != http.StatusOK || !files.gotCreateOnly || files.gotExpect != "" {
t.Fatalf("code = %d, createOnly = %v, expect = %q (%s)", w.Code, files.gotCreateOnly, files.gotExpect, w.Body.String())
}
w = do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, jsonHeader)
if w.Code != http.StatusOK || files.gotCreateOnly {
t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, files.gotCreateOnly)
}
})
t.Run("create_only with an expected hash -> 400", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=a.yml",
`{"content":"","create_only":true,"expect_sha256":"`+testSum+`"}`, jsonHeader)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "bad_request" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s), want 400 and no Job", w.Code, files.calls, w.Body.String())
}
})
}
// doUpload sends an upload whose Content-Length is declared, not measured, the
// way a client that streams or lies would send it.
func doUpload(h http.Handler, body string, length int64) *httptest.ResponseRecorder {
r := httptest.NewRequest("PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar", strings.NewReader(body))
r.Header.Set("Content-Type", "application/octet-stream")
r.ContentLength = length
w := httptest.NewRecorder()
h.ServeHTTP(w, r)
recordContract(r, body, w)
return w
}
// stageEmpty asserts no staged upload is left on disk.
func stageEmpty(t *testing.T, api *API) {
t.Helper()
left, err := os.ReadDir(api.FileStage.Dir)
if err != nil {
t.Fatalf("read the stage: %v", err)
}
if len(left) != 0 {
t.Fatalf("staged files left behind: %v", left)
}
}
// TestFileUpload drives an upload across both faces: the external PUT stages the
// body, and the executor, standing in for the Job, fetches it from the internal
// face with the token it was handed, as cmd/felis files does.
func TestFileUpload(t *testing.T) {
owner := &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}
const body = "PK\x03\x04 a plugin jar"
digest := sha256.Sum256([]byte(body))
sum := hex.EncodeToString(digest[:])
const route = "/api/v1/servers/survival/files/upload?path=plugins/x.jar"
octet := ctHeader("application/octet-stream")
t.Run("the Job fetches the body once, with its token alone", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
// Every other internal route wants a service token; the Job has none.
api.Internal = CallerTokens{CallerVelocity: "s3cr3t"}
prefix := api.InternalBaseURL + "/api/v1/internal/file-uploads/"
var bare, wrong, unschemed, fetched, again *httptest.ResponseRecorder
files.onUpload = func(src fileedit.UploadSource) {
id, ok := strings.CutPrefix(src.URL, prefix)
if !ok || len(id) != 32 {
t.Errorf("source URL %q is not one id under %q", src.URL, prefix)
return
}
h := api.InternalHandler()
at := "/api/v1/internal/file-uploads/" + id
bare = do(h, "GET", at, "", nil)
wrong = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + strings.Repeat("0", len(src.Token))})
unschemed = do(h, "GET", at, "", map[string]string{"Authorization": src.Token})
fetched = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
again = do(h, "GET", at, "", map[string]string{"Authorization": "Bearer " + src.Token})
}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if fetched == nil {
t.Fatal("the executor never fetched the upload")
}
for name, r := range map[string]*httptest.ResponseRecorder{
"no token": bare, "wrong token": wrong, "the token without Bearer": unschemed, "second fetch": again,
} {
if r.Code != http.StatusNotFound || decodeErr(t, r) != "not_found" {
t.Errorf("%s: code = %d (%s), want 404 not_found", name, r.Code, r.Body.String())
}
}
if fetched.Code != http.StatusOK || fetched.Body.String() != body ||
fetched.Header().Get("Content-Length") != strconv.Itoa(len(body)) {
t.Fatalf("fetch: code = %d, %q, Content-Length %q", fetched.Code, fetched.Body.String(), fetched.Header().Get("Content-Length"))
}
if files.gotPath != "plugins/x.jar" || files.gotSource.Size != int64(len(body)) ||
files.gotSource.SHA256 != sum || files.gotOverwrite {
t.Fatalf("executor saw path %q, source %+v, overwrite %v", files.gotPath, files.gotSource, files.gotOverwrite)
}
want := map[string]any{"path": "plugins/x.jar", "status": "uploaded", "sha256": sum, "size": float64(len(body))}
if got := fileAnswer(t, w); !reflect.DeepEqual(got, want) {
t.Fatalf("answer = %v, want %v", got, want)
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":false,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
stageEmpty(t, api)
})
t.Run("overwrite=true reaches the executor", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", route+"&overwrite=true", body, octet)
if w.Code != http.StatusOK || !files.gotOverwrite {
t.Fatalf("code = %d, overwrite = %v (%s)", w.Code, files.gotOverwrite, w.Body.String())
}
onlyAudit(t, repo, "file.upload", "survival:plugins/x.jar",
`{"overwrite":true,"sha256":"`+sum+`","size_bytes":`+strconv.Itoa(len(body))+`}`)
})
t.Run("a refused upload is not audited and its bytes are dropped", func(t *testing.T) {
api, repo, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
files.err = fmt.Errorf("%w: plugins/x.jar already exists", fileedit.ErrExists)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_exists" {
t.Fatalf("code = %d (%s), want 409 file_exists", w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("a refused upload was audited: %+v", repo.audits)
}
stageEmpty(t, api)
})
t.Run("a lock that cannot be taken drops the staged bytes", func(t *testing.T) {
api, _, cl, files := mkFiles(t)
api.External = staticExternal{p: owner}
cl.maintErr["survival"] = &MaintenanceBusyError{Kind: maintenance.KindBackup}
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusConflict || decodeErr(t, w) != "maintenance_in_progress" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("no Content-Length -> 411", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, -1)
if w.Code != http.StatusLengthRequired || decodeErr(t, w) != "length_required" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a declared size over the cap -> 413 before a byte is staged", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes+1)
if w.Code != http.StatusRequestEntityTooLarge || decodeErr(t, w) != "too_large" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
// Staged, and so short: the body is a few bytes of a declared 64 MiB.
t.Run("a declared size at the cap is taken", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, fileedit.MaxUploadBytes)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("a body shorter than its Content-Length -> 400 upload_incomplete", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
w := doUpload(api.ExternalHandler(), body, int64(len(body))+1)
if w.Code != http.StatusBadRequest || decodeErr(t, w) != "upload_incomplete" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
stageEmpty(t, api)
})
t.Run("a staging disk at its floor -> 507", func(t *testing.T) {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
api.FileStage.MinFree = 1
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusInsufficientStorage || decodeErr(t, w) != "upload_staging_full" || files.calls != 0 {
t.Fatalf("code = %d calls = %d (%s)", w.Code, files.calls, w.Body.String())
}
})
t.Run("uploads not wired -> 503", func(t *testing.T) {
for name, unwire := range map[string]func(*API){
"no stage": func(a *API) { a.FileStage = nil },
"no internal URL": func(a *API) { a.InternalBaseURL = "" },
} {
api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner}
unwire(api)
w := do(api.ExternalHandler(), "PUT", route, body, octet)
if w.Code != http.StatusServiceUnavailable || decodeErr(t, w) != "files_unavailable" || files.calls != 0 {
t.Fatalf("%s: code = %d calls = %d (%s)", name, w.Code, files.calls, w.Body.String())
}
}
})
t.Run("the internal route without a stage -> 404", func(t *testing.T) {
api, _, _, _ := mkFiles(t)
api.FileStage = nil
w := do(api.InternalHandler(), "GET", "/api/v1/internal/file-uploads/00112233445566778899aabbccddeeff", "",
map[string]string{"Authorization": "Bearer t"})
if w.Code != http.StatusNotFound || decodeErr(t, w) != "not_found" {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
})
}
// TestFileEditorErrorMapping proves each executor sentinel reaches the caller as the
// right status. The containment refusal mapping to 400 (not 403) is the one worth
// stating: an escaping path is a malformed request, not a permission a caller might
@@ -513,12 +926,13 @@ func TestFileEditorErrorMapping(t *testing.T) {
{"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"},
{"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"},
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.err = tc.err
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
@@ -529,7 +943,7 @@ func TestFileEditorErrorMapping(t *testing.T) {
}
t.Run("an unrecognised executor failure -> 500", func(t *testing.T) {
api, _, _, files := mkFiles()
api, _, _, files := mkFiles(t)
files.err = fmt.Errorf("the job pod exploded")
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=x", "", nil)
+19 -9
View File
@@ -6,9 +6,11 @@ import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
"felis.lolicon.best/internal/maintenance"
)
@@ -84,7 +86,8 @@ type maintenanceOp struct {
calls func() int
}
func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
func maintenanceOps(t *testing.T) (*API, *fakeCluster, []maintenanceOp) {
t.Helper()
repo := newFakeRepo()
repo.byName["survival"] = &ServerRecord{Name: "survival", OwnerID: "owner1"}
repo.backups = []fakeBackup{{view: BackupView{ID: "bk1", ServerName: "survival",
@@ -95,6 +98,8 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
restorer, backuper, files := &fakeRestorer{}, &fakeBackuper{}, &fakeFileEditor{}
api := newTestAPI(repo, cl)
api.Restorer, api.Backuper, api.Files = restorer, backuper, files
api.FileStage = &fileedit.Stage{Dir: t.TempDir(), MinFree: 1e-9}
api.InternalBaseURL = "http://felis-api-internal.felis.svc.cluster.local:8081"
api.External = staticExternal{p: &Principal{UserID: "owner1", Email: "[email protected]", Role: "user"}}
return api, cl, []maintenanceOp{
{"restore", maintenance.KindRestore, "POST", "/api/v1/servers/survival/restore-backup", "",
@@ -103,22 +108,27 @@ func maintenanceOps() (*API, *fakeCluster, []maintenanceOp) {
func() int { return backuper.calls }},
{"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk="}`, func() int { return files.calls }},
{"file mkdir", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/mkdir?path=plugins",
"", func() int { return files.calls }},
{"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar",
"", func() int { return files.calls }},
{"file rename", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/rename?path=a.txt",
`{"to":"b.txt"}`, func() int { return files.calls }},
{"file upload", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/files/upload?path=plugins/x.jar",
"PK-jar-bytes", func() int { return files.calls }},
}
}
func (op maintenanceOp) do(api *API) *httptest.ResponseRecorder {
var hdr map[string]string
if op.body != "" {
hdr = jsonHeader
}
hdr := fileRouteHeader(strings.TrimPrefix(op.name, "file "), op.body)
return do(api.ExternalHandler(), op.method, op.path, op.body, hdr)
}
func TestMaintenanceOpsTakeAndReleaseTheLock(t *testing.T) {
_, _, ops := maintenanceOps()
_, _, ops := maintenanceOps(t)
for i := range ops {
t.Run(ops[i].name, func(t *testing.T) {
api, cl, ops := maintenanceOps()
api, cl, ops := maintenanceOps(t)
op := ops[i]
if w := op.do(api); w.Code/100 != 2 {
t.Fatalf("code = %d body %s", w.Code, w.Body.String())
@@ -147,11 +157,11 @@ func TestMaintenanceOpsRefusedWhileHeld(t *testing.T) {
// wake won the race.
{"server not stopped", fmt.Errorf("wrapped: %w", ErrNotStopped), "not_stopped"},
}
_, _, ops := maintenanceOps()
_, _, ops := maintenanceOps(t)
for i := range ops {
for _, rf := range refusals {
t.Run(ops[i].name+" / "+rf.name, func(t *testing.T) {
api, cl, ops := maintenanceOps()
api, cl, ops := maintenanceOps(t)
op := ops[i]
cl.maintErr["survival"] = rf.err
w := op.do(api)
+50
View File
@@ -0,0 +1,50 @@
package fileedit
import (
"encoding/base64"
"fmt"
"strconv"
)
// splitContent is the base64 of content cut into contentChunk-sized parts, the
// values of ContentEnv_0 … ContentEnv_<n-1>. Empty content is zero parts.
func splitContent(content []byte) []string {
enc := base64.StdEncoding.EncodeToString(content)
parts := make([]string, 0, (len(enc)+contentChunk-1)/contentChunk)
for len(enc) > 0 {
n := min(len(enc), contentChunk)
parts = append(parts, enc[:n])
enc = enc[n:]
}
return parts
}
// contentPartEnv names part i of the content.
func contentPartEnv(i int) string { return ContentEnv + "_" + strconv.Itoa(i) }
// ContentFromEnv reassembles a write's content from the environment the Job spec
// set (see ContentEnv). A part count that is missing, not a number or negative,
// or a part that is not set, is an error rather than a shorter file: writing a
// truncated config would be worse than not writing at all. Parts are looked up
// rather than read so an unset one cannot pass as empty; the lookups stop at the
// first one missing, so a count larger than the spec carries costs nothing.
func ContentFromEnv(lookup func(string) (string, bool)) ([]byte, error) {
raw, _ := lookup(ContentPartsEnv)
n, err := strconv.Atoi(raw)
if err != nil || n < 0 {
return nil, fmt.Errorf("%s=%q is not a part count", ContentPartsEnv, raw)
}
var enc []byte
for i := range n {
part, ok := lookup(contentPartEnv(i))
if !ok {
return nil, fmt.Errorf("%s is not set", contentPartEnv(i))
}
enc = append(enc, part...)
}
content, err := base64.StdEncoding.DecodeString(string(enc))
if err != nil {
return nil, fmt.Errorf("the content is not valid base64: %w", err)
}
return content, nil
}
+94
View File
@@ -0,0 +1,94 @@
package fileedit
import (
"bytes"
"encoding/base64"
"strconv"
"testing"
)
// maxContentParts is how many ContentEnv parts the largest write needs.
var maxContentParts = (base64.StdEncoding.EncodedLen(MaxWriteBytes) + contentChunk - 1) / contentChunk
func mapLookup(env map[string]string) func(string) (string, bool) {
return func(name string) (string, bool) {
v, ok := env[name]
return v, ok
}
}
// contentEnv is the environment splitContent's parts become on the Job spec.
func contentEnv(content []byte) map[string]string {
parts := splitContent(content)
env := map[string]string{ContentPartsEnv: strconv.Itoa(len(parts))}
for i, p := range parts {
env[contentPartEnv(i)] = p
}
return env
}
// TestContentSplitRoundTrip: whatever the size, the parts reassemble to the
// bytes written, each part fits in contentChunk, and the count is the fewest
// that do.
func TestContentSplitRoundTrip(t *testing.T) {
full := contentChunk / 4 * 3 // bytes whose base64 is exactly one chunk
for _, tc := range []struct {
size, parts int
}{
{0, 0}, {1, 1}, {full, 1}, {full + 1, 2}, {2 * full, 2}, {2*full + 1, 3},
{MaxWriteBytes, maxContentParts},
} {
content := make([]byte, tc.size)
for i := range content {
content[i] = byte(i*31 + 7)
}
parts := splitContent(content)
if len(parts) != tc.parts {
t.Errorf("%d bytes: %d parts, want %d", tc.size, len(parts), tc.parts)
}
for i, p := range parts {
if len(p) == 0 || len(p) > contentChunk {
t.Errorf("%d bytes: part %d is %d chars, want 1..%d", tc.size, i, len(p), contentChunk)
}
}
got, err := ContentFromEnv(mapLookup(contentEnv(content)))
if err != nil || !bytes.Equal(got, content) {
t.Errorf("%d bytes: reassembled %d bytes, %v", tc.size, len(got), err)
}
}
}
// TestContentFromEnvRefusesAnIncompleteSpec: a spec that does not carry the
// whole content is an error. Writing what did arrive would truncate a config.
func TestContentFromEnvRefusesAnIncompleteSpec(t *testing.T) {
two := contentEnv(make([]byte, contentChunk)) // two parts
if two[ContentPartsEnv] != "2" {
t.Fatalf("fixture has %s parts, want 2", two[ContentPartsEnv])
}
withCount := func(n string) map[string]string {
env := map[string]string{ContentPartsEnv: n}
for k, v := range two {
if k != ContentPartsEnv {
env[k] = v
}
}
return env
}
missingPart := withCount("2")
delete(missingPart, contentPartEnv(1))
for name, env := range map[string]map[string]string{
"no count": {},
"empty count": withCount(""),
"count not a number": withCount("two"),
// A negative count would read no parts and write an empty file.
"negative count": withCount("-1"),
"count over the parts set": withCount("3"),
"a part missing": missingPart,
"not base64": {ContentPartsEnv: "1", contentPartEnv(0): "@@@@"},
} {
if got, err := ContentFromEnv(mapLookup(env)); err == nil {
t.Errorf("%s: reassembled %d bytes, want an error", name, len(got))
}
}
}
+83 -37
View File
@@ -1,7 +1,9 @@
// Package fileedit implements the server file editor (list / read / write a file
// in a server's world volume), the lever an owner reaches for when a server will
// not boot because one line of server.properties or a plugin's YAML is wrong —
// the one repair that otherwise requires a human with cluster access.
// Package fileedit implements the server file manager: list, read, write, make a
// folder, delete, rename and upload inside a server's world volume. It began as
// the lever an owner reaches for when a server will not boot because one line of
// server.properties or a plugin's YAML is wrong — the one repair that otherwise
// requires a human with cluster access — and also covers the everyday chores: drop
// in a plugin jar, clear out a folder, rename a world.
//
// felis-api cannot touch a world in-process: the world PVC is ReadWriteOnce and
// its lifecycle is owned by the operator's StatefulSet, so the API has nothing to
@@ -26,12 +28,14 @@
//
// No pods/exec, no pods/portforward, not even pods:get — the least-privilege line
// internal/platform/rbac.go draws and a test asserts. The write direction travels
// the other way, on the Job spec felis-api creates (see ContentEnv).
// the other way: an edit on the Job spec felis-api creates (see ContentEnv), an
// upload fetched by the Job from felis-api's internal face (see Stage), because a
// 64 MiB jar fits in neither a Job spec nor an environment.
//
// The price is latency: every operation is a Pod schedule + image pull, so a
// listing takes seconds rather than milliseconds. That is inherent to RWO plus a
// stopped server, not a property of this transport, and it is why the editor is a
// repair tool rather than a file manager.
// stopped server, not a property of this transport: the file manager works on a
// stopped server, one operation per Job.
//
// The Editor depends on the Runner interface, so the orchestration and the error
// mapping are unit-tested against an in-memory fake; the client-go implementation
@@ -70,6 +74,9 @@ var (
// ErrNoSpace is a write the world volume had no room for; the file is
// unchanged.
ErrNoSpace = errors.New("fileedit: the world volume is full")
// ErrExists is a create, mkdir, rename or upload whose target is already
// there.
ErrExists = errors.New("fileedit: the target already exists")
)
// Runner is the cluster-side half of one file operation: render and create the
@@ -187,7 +194,7 @@ type Editor struct {
// List returns one directory's entries, resolved under the server's world root.
// An empty path lists the world root itself.
func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool, error) {
res, err := e.run(ctx, server, OpList, path, nil, "")
res, err := e.run(ctx, server, JobParams{Op: OpList, Path: path})
if err != nil {
return nil, false, err
}
@@ -202,7 +209,7 @@ func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool,
// Read returns a file's bytes, resolved under the server's world root, and the
// SHA-256 of the file as it is on disk — the value to hand back as Write's expect.
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) {
res, err := e.run(ctx, server, OpRead, path, nil, "")
res, err := e.run(ctx, server, JobParams{Op: OpRead, Path: path})
if err != nil {
return nil, "", err
}
@@ -217,26 +224,68 @@ func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string,
// Write atomically replaces a file's contents, creating it if absent (but never
// creating parent directories — see the write helper in exec.go), and returns the
// new SHA-256. A non-empty expect makes it conditional: ErrConflict if the file no
// longer hashes to it.
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)
// longer hashes to it. createOnly refuses a path that exists with ErrExists.
func (e *Editor) Write(ctx context.Context, server, path string, content []byte, expect string, createOnly bool) (string, error) {
res, err := e.run(ctx, server, JobParams{
Op: OpWrite, Path: path, Content: content, Expect: expect, CreateOnly: createOnly,
})
if err != nil {
return "", err
}
return res.SHA256, nil
}
// run is the shared body of all three operations: mint an op id, render the
// params, run the Job, and translate the Result's code into a sentinel error.
// Mkdir makes one directory; its parent must exist.
func (e *Editor) Mkdir(ctx context.Context, server, path string) error {
_, err := e.run(ctx, server, JobParams{Op: OpMkdir, Path: path})
return err
}
// Delete removes a file, a link, or a directory with everything in it.
func (e *Editor) Delete(ctx context.Context, server, path string) error {
_, err := e.run(ctx, server, JobParams{Op: OpDelete, Path: path})
return err
}
// Rename moves path to to. It never replaces an existing destination.
func (e *Editor) Rename(ctx context.Context, server, path, to string) error {
_, err := e.run(ctx, server, JobParams{Op: OpRename, Path: path, To: to})
return err
}
// UploadSource is where an upload Job fetches its bytes: a one-time URL on
// felis-api's internal face and the token that opens it (see Stage), plus the size
// and SHA-256 the fetched bytes must match.
type UploadSource struct {
URL string
Token string
Size int64
SHA256 string
}
// Upload lands the staged bytes at path. The Job refuses bytes that do not match
// src.Size and src.SHA256, so a nil error means exactly those landed. overwrite
// lets it replace an existing file; without it an existing path is ErrExists.
func (e *Editor) Upload(ctx context.Context, server, path string, src UploadSource, overwrite bool) error {
_, err := e.run(ctx, server, JobParams{
Op: OpUpload, Path: path, Overwrite: overwrite,
SourceURL: src.URL, UploadToken: src.Token, UploadSize: src.Size, UploadSHA256: src.SHA256,
})
return err
}
// run is the shared body of every operation: mint an op id, fill the rest of the
// params from the Config, run the Job, and translate the Result's code into a
// sentinel error. p carries the op and its own fields.
//
// The size check happens HERE, before a Job is created, as well as inside the Pod.
// 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
// felis-api had committed to the request, instead of as a clean 413.
func (e *Editor) run(ctx context.Context, server, op, path string, content []byte, expect string) (Result, error) {
if op == OpWrite && len(content) > MaxWriteBytes {
func (e *Editor) run(ctx context.Context, server string, p JobParams) (Result, error) {
if p.Op == OpWrite && len(p.Content) > MaxWriteBytes {
return Result{}, fmt.Errorf("%w: content is %d bytes, the limit is %d",
ErrTooLarge, len(content), MaxWriteBytes)
ErrTooLarge, len(p.Content), MaxWriteBytes)
}
cfg := e.Config.withDefaults()
@@ -252,26 +301,21 @@ func (e *Editor) run(ctx context.Context, server, op, path string, content []byt
ctx, cancel := context.WithTimeout(ctx, cfg.Timeout)
defer cancel()
payload, err := e.Runner.Run(ctx, JobParams{
Server: server,
OpID: opID,
Op: op,
Path: path,
Content: content,
Expect: expect,
WorldPVC: naming.WorldPVCName(server),
Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount,
Image: cfg.Image,
WorldsRoot: cfg.WorldsRoot,
Deadline: cfg.Deadline,
CPULimit: cfg.CPULimit,
MemLimit: cfg.MemLimit,
RunAsUser: cfg.RunAsUser,
RunAsGroup: cfg.RunAsGroup,
FSGroup: cfg.FSGroup,
TTLAfterFinished: cfg.TTLAfterFinished,
})
p.Server = server
p.OpID = opID
p.WorldPVC = naming.WorldPVCName(server)
p.Namespace = cfg.Namespace
p.ServiceAccount = cfg.ServiceAccount
p.Image = cfg.Image
p.WorldsRoot = cfg.WorldsRoot
p.Deadline = cfg.Deadline
p.CPULimit = cfg.CPULimit
p.MemLimit = cfg.MemLimit
p.RunAsUser = cfg.RunAsUser
p.RunAsGroup = cfg.RunAsGroup
p.FSGroup = cfg.FSGroup
p.TTLAfterFinished = cfg.TTLAfterFinished
payload, err := e.Runner.Run(ctx, p)
if err != nil {
return Result{}, err
}
@@ -301,6 +345,8 @@ func resultError(res Result) error {
return fmt.Errorf("%w: %s", ErrConflict, res.Error)
case CodeNoSpace:
return fmt.Errorf("%w: %s", ErrNoSpace, res.Error)
case CodeExists:
return fmt.Errorf("%w: %s", ErrExists, res.Error)
default:
return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error)
}
+53 -3
View File
@@ -82,14 +82,63 @@ func TestEditorRendersParams(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old")
sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old", false)
if err != nil {
t.Fatalf("Write: %v", err)
}
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" || sum != "new" {
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" ||
r.got[0].CreateOnly || sum != "new" {
t.Fatalf("params = %+v, sha256 = %q", r.got[0], sum)
}
})
t.Run("create-only write", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if _, err := e.Write(context.Background(), "survival", "new.yml", nil, "", true); err != nil {
t.Fatalf("Write: %v", err)
}
if !r.got[0].CreateOnly {
t.Fatalf("params = %+v, want CreateOnly", r.got[0])
}
})
t.Run("mkdir, delete and rename", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
ctx := context.Background()
if err := e.Mkdir(ctx, "survival", "plugins"); err != nil {
t.Fatalf("Mkdir: %v", err)
}
if err := e.Delete(ctx, "survival", "old.jar"); err != nil {
t.Fatalf("Delete: %v", err)
}
if err := e.Rename(ctx, "survival", "a.txt", "b.txt"); err != nil {
t.Fatalf("Rename: %v", err)
}
for i, want := range []struct{ op, path, to string }{
{OpMkdir, "plugins", ""}, {OpDelete, "old.jar", ""}, {OpRename, "a.txt", "b.txt"},
} {
p := r.got[i]
if p.Op != want.op || p.Path != want.path || p.To != want.to || p.Server != "survival" || p.WorldPVC != "world-survival-0" {
t.Errorf("call %d params = %+v, want %+v", i, p, want)
}
}
})
t.Run("upload", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
src := UploadSource{URL: "http://api/x", Token: "tok", Size: 42, SHA256: "sum"}
if err := e.Upload(context.Background(), "survival", "plugins/x.jar", src, true); err != nil {
t.Fatalf("Upload: %v", err)
}
p := r.got[0]
if p.Op != OpUpload || p.Path != "plugins/x.jar" || p.SourceURL != "http://api/x" || p.UploadToken != "tok" ||
p.UploadSize != 42 || p.UploadSHA256 != "sum" || !p.Overwrite {
t.Fatalf("params = %+v", p)
}
})
}
// TestEditorMintsAFreshOpID guards the RBAC-forced invariant from the other side:
@@ -132,6 +181,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
{"oversized", CodeTooLarge, ErrTooLarge},
{"changed since read", CodeConflict, ErrConflict},
{"volume full", CodeNoSpace, ErrNoSpace},
{"already there", CodeExists, ErrExists},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
@@ -176,7 +226,7 @@ func TestEditorRefusesOversizedWriteBeforeTheCluster(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
_, err := e.Write(context.Background(), "survival", "big.txt", make([]byte, MaxWriteBytes+1), "")
_, err := e.Write(context.Background(), "survival", "big.txt", make([]byte, MaxWriteBytes+1), "", false)
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
+378 -88
View File
@@ -19,29 +19,47 @@ import (
"felis.lolicon.best/internal/naming"
)
// The three operations the editor supports. The set is deliberately closed and
// tiny: list a directory, read a file, write a file. There is no rename, delete,
// or chmod — each would need its own containment and audit story, and none is
// required to fix a broken server.properties, which is what this subsystem exists
// for.
// The operations the editor supports: list a directory, read a file, write a
// file, make a directory, delete, rename, and upload. The set is closed; there is
// no chmod, chown, link or copy. Every op resolves every path through os.Root (see
// Execute), and each mutating op carries its own containment note below.
//
// A write DOES accept arbitrary bytes at any path inside the mount, and that is a
// real capability rather than an oversight: the root is the server's whole working
// directory (see Config.WorldsRoot), so an owner can write plugins/<x>.jar and
// Paper will load it on the next boot. It is the same power a hosting panel's file
// manager gives, scoped to a server the caller already owns and already controls
// through /command. Note what it is NOT scoped by: admin image curation. Images
// are admin-only (POST /images, POST /images/build) and modpack submissions need
// an admin verdict, so this is the one owner-tier route that lands executable code
// in a backend pod. That trade was made deliberately; if it is ever revisited, the
// guard belongs in write() below, which is the single choke point all three
// callers route through.
// A write or upload DOES land arbitrary bytes at any path inside the mount, and
// that is a real capability rather than an oversight: the root is the server's
// whole working directory (see Config.WorldsRoot), so an owner can upload
// plugins/<x>.jar and Paper will load it on the next boot. It is the same power a
// hosting panel's file manager gives, scoped to a server the caller already owns
// and already controls through /command. Note what it is NOT scoped by: admin
// image curation. Images are admin-only (POST /images, POST /images/build) and
// modpack submissions need an admin verdict, so this is the one owner-tier route
// that lands executable code in a backend pod. That trade was made deliberately;
// if it is ever revisited, the guard belongs in land() below, which is the single
// choke point both byte-landing ops route through.
const (
OpList = "list"
OpRead = "read"
OpWrite = "write"
OpList = "list"
OpRead = "read"
OpWrite = "write"
OpMkdir = "mkdir"
OpDelete = "delete"
OpRename = "rename"
OpUpload = "upload"
)
// mutates reports whether op changes the world, and so whether its Job gets the
// world mount read-write. Only list and read leave the world alone; anything else
// counts as a change. maintenance.JobKind draws the same line for the world-volume
// lock.
func mutates(op string) bool { return op != OpList && op != OpRead }
// validOp reports whether op is one the Job knows.
func validOp(op string) bool {
switch op {
case OpList, OpRead, OpWrite, OpMkdir, OpDelete, OpRename, OpUpload:
return true
}
return false
}
// Result codes. A failure that is the CALLER's fault travels back as a Result
// with a Code rather than as a non-zero exit, so felis-api can map it onto a
// precise 4xx (handlers_files.go) instead of collapsing every failure into "the
@@ -59,6 +77,10 @@ const (
// (the write is atomic), so this is the caller's volume being full rather
// than a bad request.
CodeNoSpace = "no_space"
// CodeExists is a create, mkdir, rename or upload whose target is already
// there. None of them replaces anything unless told to (an upload's
// Overwrite), so a name collision is reported rather than resolved.
CodeExists = "exists"
)
// ResultPrefix marks the single stdout line carrying the JSON Result. The Job's
@@ -70,15 +92,32 @@ const (
// JSON. Without it any stray stderr byte would corrupt every response.
const ResultPrefix = "FELIS-FILES-RESULT: "
// ContentEnv is the environment variable the write path carries new file content
// in (base64). It travels on the Job spec felis-api creates, because felis-api
// holds `jobs: create` but NOT `secrets: create` in the minecraft namespace
// (internal/platform.APIMinecraftRole) — a Secret is not available to it, so the
// Job spec is the only channel into the Pod. The consequence is that written
// content is readable by anyone holding jobs:get in the minecraft namespace,
// which is a cluster-admin-level power; it is NOT readable by felis-operator,
// felis-reaper, or any weak Job SA, none of which hold that verb.
const ContentEnv = "FELIS_FILE_CONTENT"
// ContentEnv names the environment variables the write path carries new file
// content in (base64). It travels on the Job spec felis-api creates, because
// felis-api holds `jobs: create` but NOT `secrets: create` in the minecraft
// namespace (internal/platform.APIMinecraftRole) — a Secret is not available to
// it, so the Job spec is the only channel into the Pod. The consequence is that
// written content is readable by anyone holding jobs:get in the minecraft
// namespace, which is a cluster-admin-level power; it is NOT readable by
// felis-operator, felis-reaper, or any weak Job SA, none of which hold that verb.
//
// The base64 is split across ContentEnv_0 … ContentEnv_<n-1>, with n in
// ContentPartsEnv. One variable cannot carry it: execve refuses any single
// environment string longer than MAX_ARG_STRLEN (32 pages, 128 KiB with 4 KiB
// pages), so a container whose one variable held the base64 of a 100 KiB file
// never started — the Pod failed with exit 255 before felis ran, and the save
// came back as an opaque 500. contentChunk keeps every part well under that.
const (
ContentEnv = "FELIS_FILE_CONTENT"
ContentPartsEnv = ContentEnv + "_PARTS"
contentChunk = 64 << 10
)
// UploadTokenEnv carries the one-time token an upload Job presents to felis-api
// to fetch the bytes it lands (see Stage). Like the content it rides the Job
// spec, and it opens exactly one thing — the one upload that Job was created
// for, once.
const UploadTokenEnv = "FELIS_UPLOAD_TOKEN"
// Size and count ceilings. Every one of them exists because the result travels
// through a Kubernetes object or a pod log, neither of which is an unbounded pipe:
@@ -94,10 +133,17 @@ const ContentEnv = "FELIS_FILE_CONTENT"
// - MaxEntries bounds a listing. A world's region/ directory legitimately holds
// thousands of .mca files, so this truncates rather than errors (Truncated
// says so), keeping the log line bounded while still being useful.
// - MaxUploadBytes bounds an upload. Its bytes travel neither through the Job
// spec nor the pod log — felis-api stages them and the Job fetches them — so
// the bound is the request body instead: the Cloudflare edge refuses bodies
// over 100 MB on the Free and Pro plans, and 64 MiB covers the largest plugin
// jars (a Geyser build is about 20 MiB) with room to spare. A whole world is
// a different operation (a restore), not an upload.
const (
MaxWriteBytes = 256 << 10 // 256 KiB
MaxReadBytes = 1 << 20 // 1 MiB
MaxEntries = 2000
MaxWriteBytes = 256 << 10 // 256 KiB
MaxReadBytes = 1 << 20 // 1 MiB
MaxEntries = 2000
MaxUploadBytes = 64 << 20 // 64 MiB
)
// Entry is one directory entry in a listing. It carries only what a file browser
@@ -113,7 +159,7 @@ type Entry struct {
}
// Result is the single JSON object the Job prints and felis-api parses back. One
// shape covers all three ops so the transport has exactly one thing to find and
// shape covers every op so the transport has exactly one thing to find and
// unmarshal; the op decides which fields are populated.
//
// Content is []byte, so encoding/json base64-encodes it on the way out and
@@ -134,14 +180,46 @@ type Result struct {
Truncated bool `json:"truncated,omitempty"`
// 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).
// 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
// the Result to print. root is the in-Pod mount path of the server's world PVC;
// path is the caller-supplied relative path underneath it.
// Request is one file operation. Op decides which of the other fields it reads.
type Request struct {
Op string
Path string
// To is a rename's destination.
To string
// Content and Expect are a write's bytes and precondition: when Expect is
// non-empty, the write lands only if the file's current SHA-256 (hex) equals
// it.
Content []byte
Expect string
// CreateOnly makes a write refuse a path that already exists. It is the
// panel's "new file", which must never truncate a file it did not know was
// there.
CreateOnly bool
// Upload is where an upload's bytes come from; Overwrite lets it replace a
// file already at the path.
Upload *Upload
Overwrite bool
}
// Upload describes the bytes an upload lands. Size and SHA256 are what felis-api
// received from the caller; the fetched bytes must match both before they replace
// anything.
type Upload struct {
Size int64
SHA256 string
// Open starts the transfer. It runs only once the target has passed every
// check, so a refused upload never pulls the bytes.
Open func() (io.ReadCloser, error)
}
// Execute performs one operation inside root and returns the Result to print.
// root is the in-Pod mount path of the server's world PVC; every path in req is
// relative to it.
//
// CONTAINMENT INVARIANT: every filesystem access goes through *os.Root, never
// through a path string this function assembled. os.Root is the stdlib's
@@ -162,11 +240,14 @@ type Result struct {
// to os.Root as-is and refused. Silently reinterpreting "/etc/passwd" as
// "<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
// editor must not have.
// editor must not have. mkdir, delete and rename do path.Clean the path first (so
// a trailing slash cannot make them act on a link's target), and Clean keeps both
// a leading "/" and a leading "..", so an escape stays an escape.
//
// expect is a write's precondition: when non-empty, the write lands only if the
// 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) {
// The error is an infrastructure failure: the world mount unopenable, an unknown
// op, or an upload whose transfer broke. A caller's mistake is a Result with a
// Code.
func Execute(root string, req Request) (Result, error) {
r, err := os.OpenRoot(root)
if err != nil {
// The world mount itself is unopenable: infrastructure, not caller fault.
@@ -174,19 +255,31 @@ func Execute(root, op, path string, content []byte, expect string) (Result, erro
}
defer r.Close()
path := req.Path
if path == "" {
path = "."
}
switch op {
switch req.Op {
case OpList:
return list(r, path), nil
case OpRead:
return read(r, path), nil
case OpWrite:
return write(r, path, content, expect), nil
return write(r, path, req.Content, req.Expect, req.CreateOnly), nil
case OpMkdir:
return mkdir(r, path), nil
case OpDelete:
return remove(r, path), nil
case OpRename:
return rename(r, path, req.To), nil
case OpUpload:
if req.Upload == nil {
return Result{}, errors.New("an upload needs a source")
}
return upload(r, path, *req.Upload, req.Overwrite)
default:
return Result{}, fmt.Errorf("unknown op %q", op)
return Result{}, fmt.Errorf("unknown op %q", req.Op)
}
}
@@ -339,17 +432,9 @@ func redactSecretProps(name string, content []byte) []byte {
// path this editor writes is an existing config file being corrected, so an
// unexpected mkdir would more likely be a typo materialising a stray directory in
// the world mount than an intent. A missing file is still created, so a config
// the server has not yet generated can be authored.
//
// The replacement is atomic. The bytes go to a temporary sibling that is synced
// and then renamed over the target, so a full disk, a Job killed at its deadline
// or a crashed node leaves either the old file or the new one — never the
// zero-length or half-written server.properties an in-place truncate would, which
// is a server that no longer boots. The sibling 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.
// the server has not yet generated can be authored; createOnly refuses a path that
// already exists, which is how the panel's "new file" avoids truncating a file it
// did not know was there.
//
// 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
@@ -357,12 +442,7 @@ func redactSecretProps(name string, content []byte) []byte {
// 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 {
func write(r *os.Root, name string, content []byte, expect string, createOnly bool) Result {
if len(content) > MaxWriteBytes {
// 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
@@ -370,38 +450,94 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
"content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)}
}
target, res := resolveLink(r, name)
target, mode, res := landingTarget(r, name, !createOnly)
if res.Code != "" {
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
}
}
// A write's fill never returns a transfer error, so land's error is always nil.
res, _ = land(r, name, target, mode, func(w io.Writer) error {
_, err := w.Write(content)
return err
})
if res.Code == "" {
res.SHA256 = digest(content)
}
return res
}
// landingTarget decides where a write or upload lands and with what mode. A
// symlink at name is followed only while it stays inside the root (resolveLink), so
// a planted link to a file outside is refused and the rename in land replaces the
// file the link names, never the link itself. The target keeps its mode; a new file
// gets 0644.
//
// mayExist false refuses a name that is already there in any form — file,
// directory or link, dangling or not — before any link is followed.
func landingTarget(r *os.Root, name string, mayExist bool) (string, fs.FileMode, Result) {
if !mayExist {
if _, err := r.Lstat(name); err == nil {
return "", 0, Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
} else if !errors.Is(err, fs.ErrNotExist) {
return "", 0, failure(err, name)
}
}
target, res := resolveLink(r, name)
if res.Code != "" {
return "", 0, res
}
info, err := r.Lstat(target)
switch {
case err == nil && info.IsDir():
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
case err == nil && !info.Mode().IsRegular():
return "", 0, Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a regular file", name)}
case err == nil:
return target, info.Mode().Perm(), Result{}
case errors.Is(err, fs.ErrNotExist):
return target, 0o644, Result{}
default:
return "", 0, failure(err, name)
}
}
// transferError marks an upload whose bytes could not be fetched intact. It is
// infrastructure — felis-api staged the bytes and serves them to this Job — so it
// leaves Execute as an error (a non-zero exit, a 500) rather than a caller-facing
// code.
type transferError struct{ err error }
func (e *transferError) Error() string { return "fetch upload: " + e.err.Error() }
func (e *transferError) Unwrap() error { return e.err }
// land atomically puts the bytes fill writes at target, the path landingTarget
// returned for name. It is the single choke point both byte-landing ops (write and
// upload) route through.
//
// The bytes go to a temporary sibling that is synced and then renamed over the
// target, so a full disk, a Job killed at its deadline or a crashed node leaves
// either the old file or the new one — never the zero-length or half-written
// server.properties an in-place truncate would, which is a server that no longer
// boots. The sibling gets mode and is handed to the game uid before the rename, so
// the file the server finds is never root's. On failure it is removed; only a kill
// between create and rename leaves one behind, named ".<file>.felis-edit-<hex>" so
// no loader mistakes it for a plugin jar or a config.
//
// A *transferError from fill comes back as the error; every other failure is a
// Result.
func land(r *os.Root, name, target string, mode fs.FileMode, fill func(io.Writer) error) (Result, error) {
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}, nil
}
tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:]))
f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return writeFailure(err, name)
return writeFailure(err, name), nil
}
renamed := false
defer func() {
@@ -413,21 +549,25 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
// owner had at 0664 or 0600 should come back the same.
if err := f.Chmod(mode); err != nil {
f.Close()
return writeFailure(err, name)
return writeFailure(err, name), nil
}
if _, err := f.Write(content); err != nil {
if err := fill(f); err != nil {
f.Close()
return writeFailure(err, name)
var te *transferError
if errors.As(err, &te) {
return Result{}, err
}
return writeFailure(err, name), nil
}
// Sync before the rename, or a crash could leave the new name pointing at
// 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)
return writeFailure(err, name), nil
}
if err := f.Close(); err != nil {
return writeFailure(err, name)
return writeFailure(err, name), nil
}
// The Job runs as root, so the file it just created is root's. The server runs
// as the game uid and could read it but never rewrite it — a config the panel
@@ -435,16 +575,166 @@ func write(r *os.Root, name string, content []byte, expect string) Result {
// prepare-data initContainer re-owns anything left behind on its next start.
_ = ownWritten(r, tmp)
if err := r.Rename(tmp, target); err != nil {
return writeFailure(err, name)
return writeFailure(err, name), nil
}
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 {
syncDir(r, path.Dir(target))
return Result{}, nil
}
// syncDir syncs a directory so an entry just added, renamed or removed survives a
// crash too. Best effort: the change has happened and reporting failure would lie.
func syncDir(r *os.Root, dir string) {
if d, err := r.Open(dir); err == nil {
_ = d.Sync()
d.Close()
}
return Result{SHA256: digest(content)}
}
// upload lands a file fetched from felis-api (see Stage). Everything that can
// refuse it is checked before u.Open, so a refused upload never pulls its bytes.
// The fetched bytes must match both the size and the SHA-256 felis-api received;
// either mismatch is a broken transfer, and the target is left as it was. A
// success has landed exactly what felis-api staged, whose digest it already holds.
func upload(r *os.Root, name string, u Upload, overwrite bool) (Result, error) {
if u.Size > MaxUploadBytes {
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
"the upload is %d bytes; the editor uploads at most %d", u.Size, MaxUploadBytes)}, nil
}
target, mode, res := landingTarget(r, name, overwrite)
if res.Code != "" {
return res, nil
}
return land(r, name, target, mode, func(w io.Writer) error {
body, err := u.Open()
if err != nil {
return &transferError{err}
}
defer body.Close()
h := sha256.New()
// One byte past Size so a source that sends more than it promised is seen.
n, err := io.Copy(io.MultiWriter(w, h), sourceReader{io.LimitReader(body, u.Size+1)})
if err != nil {
return err
}
if n != u.Size {
return &transferError{fmt.Errorf("got %d bytes, expected %d", n, u.Size)}
}
if sum := hex.EncodeToString(h.Sum(nil)); sum != u.SHA256 {
return &transferError{fmt.Errorf("got sha256 %s, expected %s", sum, u.SHA256)}
}
return nil
})
}
// sourceReader tags the source's read errors as transfer errors, so land can tell
// a broken fetch from the volume filling up underneath the copy.
type sourceReader struct{ r io.Reader }
func (s sourceReader) Read(p []byte) (int, error) {
n, err := s.r.Read(p)
if err != nil && err != io.EOF {
err = &transferError{err}
}
return n, err
}
// mkdir makes one directory, handed to the game uid. It does not make parents:
// the panel creates a folder inside the one it is showing, so a missing parent is
// a stale view, reported as such.
//
// The path is cleaned first so a trailing slash cannot slip past the "." check.
// Clean keeps a leading "/" or "..", so os.Root still sees — and refuses — an
// escape.
func mkdir(r *os.Root, name string) Result {
name = path.Clean(name)
if name == "." {
return Result{Code: CodeBadPath, Error: "a folder needs a name"}
}
if err := r.Mkdir(name, 0o755); err != nil {
switch {
case errors.Is(err, fs.ErrExist):
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", name)}
case errors.Is(err, fs.ErrNotExist):
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(name))}
}
return writeFailure(err, name)
}
_ = ownWritten(r, name)
syncDir(r, path.Dir(name))
return Result{}
}
// remove deletes a file, a link or a whole directory. RemoveAll removes a symlink
// itself, never what it points at, and os.Root keeps it inside the mount; the Lstat
// is there because RemoveAll reports nothing for a path that is not there. The
// root itself is refused — emptying a server's whole volume is a reset, which has
// its own path.
func remove(r *os.Root, name string) Result {
name = path.Clean(name)
if name == "." {
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be deleted"}
}
if _, err := r.Lstat(name); err != nil {
return failure(err, name)
}
if err := r.RemoveAll(name); err != nil {
return failure(err, name)
}
syncDir(r, path.Dir(name))
return Result{}
}
// rename moves from to to, both inside the root. It never replaces: a destination
// that exists is CodeExists, so a mistyped name cannot silently destroy another
// file. A missing destination folder is not made.
func rename(r *os.Root, from, to string) Result {
from, to = path.Clean(from), path.Clean(to)
if from == "." || to == "." {
return Result{Code: CodeBadPath, Error: "the server's root folder cannot be moved"}
}
info, err := r.Lstat(from)
if err != nil {
return failure(err, from)
}
if res := guardMove(r, from, info); res.Code != "" {
return res
}
if _, err := r.Lstat(to); err == nil {
return Result{Code: CodeExists, Error: fmt.Sprintf("%s already exists", to)}
} else if !errors.Is(err, fs.ErrNotExist) {
return failure(err, to)
}
if err := r.Rename(from, to); err != nil {
if errors.Is(err, fs.ErrNotExist) {
return Result{Code: CodeNotFound, Error: fmt.Sprintf("folder %s does not exist", path.Dir(to))}
}
return failure(err, to)
}
syncDir(r, path.Dir(from))
syncDir(r, path.Dir(to))
return Result{}
}
// guardedPaths are the paths read guards by name: secretConfigPath is refused and
// propsPath has its RCON password redacted. Moving either — or the config folder
// holding the first — to another name would make the next read hand back what the
// guard withholds, so rename refuses them.
var guardedPaths = []string{secretConfigPath, path.Dir(secretConfigPath), propsPath}
// guardMove refuses a rename whose source is a guarded path under any name: the
// comparison is by file identity, so "./config", a link's target or a folder
// reached through a link are all caught.
func guardMove(r *os.Root, from string, info fs.FileInfo) Result {
for _, g := range guardedPaths {
for _, stat := range []func(string) (fs.FileInfo, error){r.Lstat, r.Stat} {
if gi, err := stat(g); err == nil && os.SameFile(info, gi) {
return Result{Code: CodeBadPath, Error: fmt.Sprintf(
"%s is managed by felis and cannot be moved or renamed", from)}
}
}
}
return Result{}
}
// writeFailure is failure for the steps that move bytes, where a full volume is
+33 -28
View File
@@ -36,6 +36,11 @@ func worldRoot(t *testing.T) (root, outside string) {
return root, outside
}
// run executes one list, read or write the way the Job does.
func run(root, op, path string, content []byte, expect string) (Result, error) {
return Execute(root, Request{Op: op, Path: path, Content: content, Expect: expect})
}
// TestExecuteContainment is the security test of this package. The world directory
// holds attacker-influenced content (players and plugins create files in it), so
// each vector below is a path a caller could genuinely supply to try to leave the
@@ -76,7 +81,7 @@ func TestExecuteContainment(t *testing.T) {
for _, v := range vectors {
t.Run("read "+v.name, func(t *testing.T) {
res, err := Execute(root, OpRead, v.path, nil, "")
res, err := run(root, OpRead, v.path, nil, "")
if err != nil {
t.Fatalf("Execute returned an infrastructure error, want a contained refusal: %v", err)
}
@@ -92,7 +97,7 @@ func TestExecuteContainment(t *testing.T) {
// The write side must be contained by the same invariant: a planted symlink
// must not become a write into the file it points at.
t.Run("write through a planted symlink is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "planted.txt", []byte("pwned"), "")
res, err := run(root, OpWrite, "planted.txt", []byte("pwned"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -109,7 +114,7 @@ func TestExecuteContainment(t *testing.T) {
})
t.Run("write escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"), "")
res, err := run(root, OpWrite, "../outside/new.txt", []byte("pwned"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -122,7 +127,7 @@ func TestExecuteContainment(t *testing.T) {
})
t.Run("list escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpList, "../outside", nil, "")
res, err := run(root, OpList, "../outside", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -139,7 +144,7 @@ func TestExecuteHappyPath(t *testing.T) {
root, _ := worldRoot(t)
t.Run("list the world root", func(t *testing.T) {
res, err := Execute(root, OpList, "", nil, "")
res, err := run(root, OpList, "", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -162,7 +167,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("list a subdirectory", func(t *testing.T) {
res, err := Execute(root, OpList, "config", nil, "")
res, err := run(root, OpList, "config", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -172,7 +177,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("read a file", func(t *testing.T) {
res, err := Execute(root, OpRead, "server.properties", nil, "")
res, err := run(root, OpRead, "server.properties", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -182,7 +187,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("write replaces content, then reads back", func(t *testing.T) {
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=changed\n"), ""); err != nil || res.Code != "" {
if res, err := run(root, OpWrite, "server.properties", []byte("motd=changed\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write failed: %v / %+v", err, res)
}
b, err := os.ReadFile(filepath.Join(root, "server.properties"))
@@ -202,13 +207,13 @@ func TestExecuteHappyPath(t *testing.T) {
return os.ErrPermission // a test runner cannot chown; the write must still succeed
}
defer func() { ownWritten = prev }()
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" {
if res, err := run(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" {
t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
}
if len(owned) != 1 || !strings.HasPrefix(owned[0], ".ops.json.felis-edit-") {
t.Errorf("files handed to the game uid = %v, want the one temporary sibling of ops.json", owned)
}
res, err := Execute(root, OpWrite, "nope/deep.txt", []byte("x"), "")
res, err := run(root, OpWrite, "nope/deep.txt", []byte("x"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -218,7 +223,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("missing file reads as not_found", func(t *testing.T) {
res, err := Execute(root, OpRead, "absent.txt", nil, "")
res, err := run(root, OpRead, "absent.txt", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -228,7 +233,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("reading a directory is bad_path, not a garbled read", func(t *testing.T) {
res, err := Execute(root, OpRead, "config", nil, "")
res, err := run(root, OpRead, "config", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -238,7 +243,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("oversized write is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "")
res, err := run(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -251,7 +256,7 @@ func TestExecuteHappyPath(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, "huge.bin"), make([]byte, MaxReadBytes+1), 0o644); err != nil {
t.Fatalf("write huge: %v", err)
}
res, err := Execute(root, OpRead, "huge.bin", nil, "")
res, err := run(root, OpRead, "huge.bin", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -272,7 +277,7 @@ func TestReadIsBinarySafe(t *testing.T) {
t.Fatalf("write raw: %v", err)
}
res, err := Execute(root, OpRead, "raw.bin", nil, "")
res, err := run(root, OpRead, "raw.bin", nil, "")
if err != nil || res.Code != "" {
t.Fatalf("read failed: %v / %+v", err, res)
}
@@ -333,7 +338,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
"config/../config/paper-global.yml",
"config/./paper-global.yml",
} {
res, err := Execute(root, OpRead, spelling, nil, "")
res, err := run(root, OpRead, spelling, nil, "")
if err != nil {
t.Fatalf("%s: Execute: %v", spelling, err)
}
@@ -348,7 +353,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// The denial is READ-only and exact: a neighbouring file in the same directory
// stays readable, or the guard would have broken ordinary config repair.
res, err := Execute(root, OpRead, "config/paper.yml", nil, "")
res, err := run(root, OpRead, "config/paper.yml", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -358,7 +363,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// Writing it is still allowed: it leaks nothing, and the lobby entrypoint
// rewrites the file whole on every boot regardless.
res, err = Execute(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"), "")
res, err = run(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -387,7 +392,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
t.Fatalf("write nested server.properties: %v", err)
}
res, err := Execute(root, OpRead, "server.properties", nil, "")
res, err := run(root, OpRead, "server.properties", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -406,7 +411,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
}
}
nested, err := Execute(root, OpRead, "plugins/server.properties", nil, "")
nested, err := run(root, OpRead, "plugins/server.properties", nil, "")
if err != nil {
t.Fatalf("Execute nested: %v", err)
}
@@ -426,7 +431,7 @@ func TestWriteIsAtomic(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"), "")
res, err := run(root, OpWrite, "server.properties", []byte("motd=half"), "")
if err != nil || res.Code != CodeNoSpace {
t.Fatalf("Execute = %+v, %v; want no_space", res, err)
}
@@ -440,7 +445,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Chmod(props, 0o600); err != nil {
t.Fatal(err)
}
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" {
if res, err := run(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res)
}
info, err := os.Stat(props)
@@ -457,7 +462,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if res, err := Execute(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" {
if res, err := run(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res)
}
if b, _ := os.ReadFile(filepath.Join(root, "config", "paper.yml")); string(b) != "verbose: true\n" {
@@ -472,7 +477,7 @@ func TestWriteIsAtomic(t *testing.T) {
if err := os.Symlink("../../outside/secret.txt", filepath.Join(root, "config", "climb")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
res, err := Execute(root, OpWrite, "config/climb", []byte("pwned"), "")
res, err := run(root, OpWrite, "config/climb", []byte("pwned"), "")
if err != nil || res.Code != CodeBadPath {
t.Fatalf("Execute = %+v, %v; want bad_path", res, err)
}
@@ -488,7 +493,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
t.Fatal(err)
}
read, err := Execute(root, OpRead, "server.properties", nil, "")
read, err := run(root, OpRead, "server.properties", nil, "")
if err != nil || read.Code != "" || len(read.SHA256) != 64 {
t.Fatalf("read = %+v, %v; want content and a sha256", read, err)
}
@@ -502,7 +507,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
if err := os.WriteFile(props, []byte("motd=theirs\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256)
res, err := run(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256)
if err != nil || res.Code != CodeConflict {
t.Fatalf("stale write = %+v, %v; want a conflict", res, err)
}
@@ -514,7 +519,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
}
// With the current hash the save lands and reports the new one.
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
res, err = run(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != "" || res.SHA256 != digest([]byte("motd=mine\n")) {
t.Fatalf("fresh write = %+v, %v", res, err)
}
@@ -523,7 +528,7 @@ func TestWriteDetectsConcurrentChange(t *testing.T) {
if err := os.Remove(props); err != nil {
t.Fatal(err)
}
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
res, err = run(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != CodeConflict {
t.Fatalf("write over a deleted file = %+v, %v; want a conflict", res, err)
}
+65 -28
View File
@@ -1,8 +1,8 @@
package fileedit
import (
"encoding/base64"
"fmt"
"strconv"
"time"
batchv1 "k8s.io/api/batch/v1"
@@ -46,9 +46,20 @@ type JobParams struct {
Path string
Content []byte // OpWrite only
// Expect is a write's precondition hash (see Execute); empty writes
// unconditionally.
Expect string
WorldPVC string
// unconditionally. CreateOnly makes a write refuse an existing path.
Expect string
CreateOnly bool
// To is a rename's destination.
To string
// SourceURL, UploadToken, UploadSize and UploadSHA256 tell an upload Job where
// to fetch its bytes and what they must be (see Stage); Overwrite lets it
// replace an existing file.
SourceURL string
UploadToken string
UploadSize int64
UploadSHA256 string
Overwrite bool
WorldPVC string
Namespace string
ServiceAccount string
@@ -104,8 +115,8 @@ func filesLabels(p JobParams) map[string]string {
// mounts the config Secret to self-record its row: a file-editor Pod has nothing
// to record, so it is handed no database URL and no credential of any kind (the
// four-power red line, spec §22);
// - mounts that one volume READ-ONLY for list and read. Only a write needs to
// mutate the world, so two of the three operations physically cannot — the
// - mounts that one volume READ-ONLY for list and read (see mutates), so the
// two operations that only look physically cannot change anything — the
// kernel refuses, not merely the code. This is why readOnly is derived from the
// op rather than fixed;
// - runs as a non-root, fixed uid/gid with an fsGroup matching the operator's
@@ -113,7 +124,8 @@ func filesLabels(p JobParams) map[string]string {
// server later runs as — a config file the server cannot read would be worse
// than no edit at all;
// - no privilege, no privilege escalation, read-only root filesystem, drop ALL
// capabilities. The world mount is the only writable path, and only on a write;
// capabilities. The world mount is the only writable path, and only for an op
// that mutates;
// - activeDeadlineSeconds + backoffLimit=0 so a wedged mount cannot loop or hang
// forever; ttlSecondsAfterFinished GCs the finished Job, which — see
// FilesJobName — is the ONLY cleanup available to felis-api.
@@ -131,12 +143,15 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
if p.OpID == "" {
return nil, fmt.Errorf("fileedit: op id is required")
}
if p.Op != OpList && p.Op != OpRead && p.Op != OpWrite {
if !validOp(p.Op) {
return nil, fmt.Errorf("fileedit: unknown op %q", p.Op)
}
if len(p.Content) > MaxWriteBytes {
return nil, fmt.Errorf("fileedit: content is %d bytes, over the %d limit", len(p.Content), MaxWriteBytes)
}
if p.Op == OpUpload && (p.SourceURL == "" || p.UploadToken == "") {
return nil, fmt.Errorf("fileedit: an upload needs a source URL and a token")
}
limits, err := resourceLimits(p.CPULimit, p.MemLimit)
if err != nil {
return nil, err
@@ -150,10 +165,10 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
ttl = int32(defaultTTL / time.Second)
}
// Only a write may mutate the world. Mounting read-only for the other two ops
// makes "a listing cannot damage a world" a kernel guarantee rather than a
// code-review one.
readOnlyWorld := p.Op != OpWrite
// Only an op that changes the world gets it read-write. Mounting read-only for
// list and read makes "a listing cannot damage a world" a kernel guarantee
// rather than a code-review one.
readOnlyWorld := !mutates(p.Op)
args := []string{
"--op", p.Op,
@@ -162,8 +177,24 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
}
// 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)
switch p.Op {
case OpWrite:
if p.Expect != "" {
args = append(args, "--expect-sha256", p.Expect)
}
if p.CreateOnly {
args = append(args, "--create-only")
}
case OpRename:
args = append(args, "--to", p.To)
case OpUpload:
// The URL, size and digest are not secrets — only the token is, and it
// rides the environment below.
args = append(args, "--source-url", p.SourceURL,
"--size", strconv.FormatInt(p.UploadSize, 10), "--sha256", p.UploadSHA256)
if p.Overwrite {
args = append(args, "--overwrite")
}
}
container := corev1.Container{
Name: containerName,
@@ -185,16 +216,21 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
},
}
// New content rides the Job spec as a base64 env var. felis-api cannot create a
// Secret (it holds secrets:get only), so the spec is the sole channel into the
// Pod; base64 keeps arbitrary bytes — CRLF line endings, a UTF-8 BOM, a binary
// blob — intact through a field that must be a valid string. The env var is set
// ONLY for a write, so a list/read Job spec carries no caller content at all.
if p.Op == OpWrite {
container.Env = []corev1.EnvVar{{
Name: ContentEnv,
Value: base64.StdEncoding.EncodeToString(p.Content),
}}
// New content rides the Job spec as base64 env vars (see ContentEnv for why
// it is several). felis-api cannot create a Secret (it holds secrets:get only),
// so the spec is the sole channel into the Pod; base64 keeps arbitrary bytes —
// CRLF line endings, a UTF-8 BOM, a binary blob — intact through a field that
// must be a valid string. Content is set ONLY for a write and the token ONLY
// for an upload, so no other Job spec carries either.
switch p.Op {
case OpWrite:
parts := splitContent(p.Content)
container.Env = []corev1.EnvVar{{Name: ContentPartsEnv, Value: strconv.Itoa(len(parts))}}
for i, part := range parts {
container.Env = append(container.Env, corev1.EnvVar{Name: contentPartEnv(i), Value: part})
}
case OpUpload:
container.Env = []corev1.EnvVar{{Name: UploadTokenEnv, Value: p.UploadToken}}
}
job := &batchv1.Job{
@@ -261,11 +297,12 @@ func int64Ptr(i int64) *int64 { return &i }
// filesCapabilities is what the root executor keeps after dropping ALL (see
// Config.RunAsUser). DAC_OVERRIDE opens a mode-0600 file (level.dat) the game wrote
// as its own uid, which a fixed non-root uid could not. A write also keeps CHOWN so
// the file it creates can be handed to naming.GameUID (exec.go ownWritten); a list
// or read changes nothing and gets no more than it needs.
// as its own uid, which a fixed non-root uid could not. A write, mkdir or upload
// also keeps CHOWN so what it creates can be handed to naming.GameUID (exec.go
// ownWritten). List, read, delete and rename create nothing and get no more than
// they need.
func filesCapabilities(op string) []corev1.Capability {
if op == OpWrite {
if op == OpWrite || op == OpMkdir || op == OpUpload {
return []corev1.Capability{"CHOWN", "DAC_OVERRIDE"}
}
return []corev1.Capability{"DAC_OVERRIDE"}
+173 -28
View File
@@ -1,7 +1,10 @@
package fileedit
import (
"bytes"
"encoding/base64"
"slices"
"strconv"
"strings"
"testing"
"time"
@@ -9,6 +12,18 @@ import (
corev1 "k8s.io/api/core/v1"
)
// opParams is testParams with what each op needs to render.
func opParams(op string) JobParams {
p := testParams(op)
switch op {
case OpRename:
p.To = "server.properties.bak"
case OpUpload:
p.SourceURL, p.UploadToken = "http://felis-api-internal.felis.svc:8081/api/v1/internal/file-uploads/0a", "tok"
}
return p
}
func testParams(op string) JobParams {
return JobParams{
Server: "survival",
@@ -105,16 +120,28 @@ func TestFilesJobIsolation(t *testing.T) {
}
})
// Only a write creates a file it must hand back to the game uid, so only a
// write keeps CHOWN; a read stays at DAC_OVERRIDE alone (asserted above).
t.Run("a write also keeps CHOWN", func(t *testing.T) {
w, err := FilesJob(testParams(OpWrite))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
add := w.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add
if len(add) != 2 || add[0] != "CHOWN" || add[1] != "DAC_OVERRIDE" {
t.Fatalf("write capabilities = %v, want [CHOWN DAC_OVERRIDE]", add)
// The ops that create a file or folder hand it back to the game uid, so they
// keep CHOWN; the rest stay at DAC_OVERRIDE alone.
t.Run("only the creating ops keep CHOWN", func(t *testing.T) {
for _, tc := range []struct {
op string
chown bool
}{
{OpList, false}, {OpRead, false}, {OpDelete, false}, {OpRename, false},
{OpWrite, true}, {OpMkdir, true}, {OpUpload, true},
} {
j, err := FilesJob(opParams(tc.op))
if err != nil {
t.Fatalf("%s: FilesJob: %v", tc.op, err)
}
add := j.Spec.Template.Spec.Containers[0].SecurityContext.Capabilities.Add
want := []corev1.Capability{"DAC_OVERRIDE"}
if tc.chown {
want = []corev1.Capability{"CHOWN", "DAC_OVERRIDE"}
}
if !slices.Equal(add, want) {
t.Errorf("%s capabilities = %v, want %v", tc.op, add, want)
}
}
})
@@ -174,10 +201,10 @@ func TestFilesJobExpectArg(t *testing.T) {
}
}
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
// write can mutate a world. For list and read the kernel refuses the write, not
// TestFilesJobWorldMountIsReadOnlyForReads pins the guarantee that list and read
// cannot mutate a world. For them the kernel refuses the write, not
// merely the code — a defence that survives a bug in the entrypoint.
func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
func TestFilesJobWorldMountIsReadOnlyForReads(t *testing.T) {
cases := []struct {
op string
wantReadOnly bool
@@ -185,10 +212,14 @@ func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
{OpList, true},
{OpRead, true},
{OpWrite, false},
{OpMkdir, false},
{OpDelete, false},
{OpRename, false},
{OpUpload, false},
}
for _, tc := range cases {
t.Run(tc.op, func(t *testing.T) {
job, err := FilesJob(testParams(tc.op))
job, err := FilesJob(opParams(tc.op))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
@@ -203,11 +234,23 @@ func TestFilesJobWorldMountIsReadOnlyExceptForWrite(t *testing.T) {
}
}
// envLookup reads a rendered container's environment the way the Job's process
// sees it.
func envLookup(env []corev1.EnvVar) func(string) (string, bool) {
return func(name string) (string, bool) {
for _, e := range env {
if e.Name == name {
return e.Value, true
}
}
return "", false
}
}
// TestFilesJobContentEnv pins the write channel: content rides the Job spec
// base64-encoded, and ONLY for a write — a list or read Job spec must carry no
// caller content at all.
// base64-encoded, and ONLY for a write — no other Job spec carries caller content.
func TestFilesJobContentEnv(t *testing.T) {
t.Run("write carries base64 content", func(t *testing.T) {
t.Run("write carries the content, reassembled by the entrypoint", func(t *testing.T) {
p := testParams(OpWrite)
p.Content = []byte("motd=hello\n\x00\xff")
job, err := FilesJob(p)
@@ -215,15 +258,16 @@ func TestFilesJobContentEnv(t *testing.T) {
t.Fatalf("FilesJob: %v", err)
}
env := job.Spec.Template.Spec.Containers[0].Env
if len(env) != 1 || env[0].Name != ContentEnv {
t.Fatalf("env = %+v, want exactly %s", env, ContentEnv)
want := []corev1.EnvVar{
{Name: ContentPartsEnv, Value: "1"},
{Name: ContentEnv + "_0", Value: base64.StdEncoding.EncodeToString(p.Content)},
}
got, err := base64.StdEncoding.DecodeString(env[0].Value)
if err != nil {
t.Fatalf("env value is not base64: %v", err)
if !slices.Equal(env, want) {
t.Fatalf("env = %+v, want %+v", env, want)
}
if string(got) != string(p.Content) {
t.Fatalf("decoded %q, want %q — arbitrary bytes must survive", got, p.Content)
got, err := ContentFromEnv(envLookup(env))
if err != nil || string(got) != string(p.Content) {
t.Fatalf("reassembled %q, %v; want %q — arbitrary bytes must survive", got, err, p.Content)
}
// The content must never leak into argv, which is world-readable on the node.
if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") {
@@ -231,9 +275,52 @@ func TestFilesJobContentEnv(t *testing.T) {
}
})
for _, op := range []string{OpList, OpRead} {
t.Run(op+" carries no content env", func(t *testing.T) {
job, err := FilesJob(testParams(op))
// execve refuses one environment string over 128 KiB and the container never
// starts, so the largest write must arrive in parts each well under it.
t.Run("the largest write is split under the kernel's per-variable limit", func(t *testing.T) {
p := testParams(OpWrite)
p.Content = make([]byte, MaxWriteBytes)
for i := range p.Content {
p.Content[i] = byte(i * 7)
}
job, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
env := job.Spec.Template.Spec.Containers[0].Env
if len(env) != 1+maxContentParts || env[0].Value != strconv.Itoa(maxContentParts) {
t.Fatalf("%d variables, count %q; want the count and %d parts", len(env), env[0].Value, maxContentParts)
}
for _, e := range env {
if len(e.Value) > contentChunk || len(e.Name)+1+len(e.Value) >= 128<<10 {
t.Fatalf("%s is %d bytes; each part must fit in %d", e.Name, len(e.Value), contentChunk)
}
}
got, err := ContentFromEnv(envLookup(env))
if err != nil || !bytes.Equal(got, p.Content) {
t.Fatalf("reassembled %d bytes, %v; want the %d written", len(got), err, len(p.Content))
}
})
t.Run("an empty write is zero parts", func(t *testing.T) {
p := testParams(OpWrite)
p.Content = []byte{}
job, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
env := job.Spec.Template.Spec.Containers[0].Env
if want := []corev1.EnvVar{{Name: ContentPartsEnv, Value: "0"}}; !slices.Equal(env, want) {
t.Fatalf("env = %+v, want %+v", env, want)
}
if got, err := ContentFromEnv(envLookup(env)); err != nil || len(got) != 0 {
t.Fatalf("reassembled %q, %v; want empty", got, err)
}
})
for _, op := range []string{OpList, OpRead, OpMkdir, OpDelete, OpRename} {
t.Run(op+" carries no env", func(t *testing.T) {
job, err := FilesJob(opParams(op))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
@@ -244,6 +331,62 @@ func TestFilesJobContentEnv(t *testing.T) {
}
}
// TestFilesJobOpArgs pins what each op hands the entrypoint beyond --op and
// --path, and that the upload token rides the environment, never argv.
func TestFilesJobOpArgs(t *testing.T) {
args := func(p JobParams) []string {
t.Helper()
j, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob(%s): %v", p.Op, err)
}
return j.Spec.Template.Spec.Containers[0].Args[6:]
}
read, err := FilesJob(testParams(OpRead))
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
if got, want := read.Spec.Template.Spec.Containers[0].Args, []string{"--op", "read", "--path", "server.properties", "--worlds-root", "/data"}; !slices.Equal(got, want) {
t.Fatalf("read args = %v, want %v", got, want)
}
create := testParams(OpWrite)
create.CreateOnly = true
if got := args(create); !slices.Equal(got, []string{"--create-only"}) {
t.Errorf("create-only write args = %v", got)
}
readCreate := testParams(OpRead)
readCreate.CreateOnly = true
if got := args(readCreate); len(got) != 0 {
t.Errorf("a read carries write flags: %v", got)
}
if got := args(opParams(OpRename)); !slices.Equal(got, []string{"--to", "server.properties.bak"}) {
t.Errorf("rename args = %v", got)
}
up := opParams(OpUpload)
up.UploadSize, up.UploadSHA256 = 1234, strings.Repeat("c", 64)
want := []string{"--source-url", up.SourceURL, "--size", "1234", "--sha256", strings.Repeat("c", 64)}
if got := args(up); !slices.Equal(got, want) {
t.Errorf("upload args = %v, want %v", got, want)
}
up.Overwrite = true
if got := args(up); !slices.Equal(got, append(want, "--overwrite")) {
t.Errorf("overwriting upload args = %v", got)
}
j, err := FilesJob(up)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
c := j.Spec.Template.Spec.Containers[0]
if want := []corev1.EnvVar{{Name: UploadTokenEnv, Value: "tok"}}; !slices.Equal(c.Env, want) {
t.Errorf("upload env = %+v, want %+v", c.Env, want)
}
if slices.Contains(c.Args, "tok") {
t.Errorf("the upload token is in argv: %v", c.Args)
}
}
// TestFilesJobNameIsPerInvocation is the RBAC-forced property documented on
// FilesJobName. felis-api holds jobs:create and NOTHING else — no jobs:delete — so
// a deterministic name would let the first completed Job squat it for a whole TTL
@@ -285,7 +428,9 @@ func TestFilesJobRejectsBadParams(t *testing.T) {
{"no image", func(p *JobParams) { p.Image = "" }},
{"no world PVC", func(p *JobParams) { p.WorldPVC = "" }},
{"no op id", func(p *JobParams) { p.OpID = "" }},
{"unknown op", func(p *JobParams) { p.Op = "delete" }},
{"unknown op", func(p *JobParams) { p.Op = "chmod" }},
{"upload without a source", func(p *JobParams) { p.Op, p.UploadToken = OpUpload, "tok" }},
{"upload without a token", func(p *JobParams) { p.Op, p.SourceURL = OpUpload, "http://x" }},
{"oversized content", func(p *JobParams) {
p.Op, p.Content = OpWrite, make([]byte, MaxWriteBytes+1)
}},
+580
View File
@@ -0,0 +1,580 @@
package fileedit
import (
"errors"
"io"
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
// mustRead returns a file's content, failing the test if it is not there.
func mustRead(t *testing.T, p string) string {
t.Helper()
b, err := os.ReadFile(p)
if err != nil {
t.Fatalf("read %s: %v", p, err)
}
return string(b)
}
// assertAbsent fails if anything, a dangling link included, is at p.
func assertAbsent(t *testing.T, p string) {
t.Helper()
if _, err := os.Lstat(p); !errors.Is(err, os.ErrNotExist) {
t.Fatalf("%s exists (%v), want nothing there", p, err)
}
}
func symlink(t *testing.T, target, link string) {
t.Helper()
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
}
// exec runs one request and fails the test on an infrastructure error.
func exec(t *testing.T, root string, req Request) Result {
t.Helper()
res, err := Execute(root, req)
if err != nil {
t.Fatalf("Execute(%+v): %v", req, err)
}
return res
}
// TestWriteCreateOnly: the panel's "new file" lands only where nothing is.
func TestWriteCreateOnly(t *testing.T) {
root, _ := worldRoot(t)
create := func(name string) Result {
return exec(t, root, Request{Op: OpWrite, Path: name, Content: []byte("new: true\n"), CreateOnly: true})
}
t.Run("a free path is created", func(t *testing.T) {
res := create("config/new.yml")
if res.Code != "" || res.SHA256 != digest([]byte("new: true\n")) {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "config", "new.yml")); got != "new: true\n" {
t.Fatalf("content = %q", got)
}
})
t.Run("an existing file is refused and left alone", func(t *testing.T) {
if res := create("server.properties"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
})
t.Run("a folder is refused as existing", func(t *testing.T) {
if res := create("config"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
})
// A plain write follows a link inside the root and creates what it names; a
// create must not, or "new file" would land somewhere the caller never named.
t.Run("a dangling link is refused, never followed", func(t *testing.T) {
symlink(t, "config/elsewhere.yml", filepath.Join(root, "dangling.yml"))
if res := create("dangling.yml"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
assertAbsent(t, filepath.Join(root, "config", "elsewhere.yml"))
})
}
func TestMkdir(t *testing.T) {
root, outside := worldRoot(t)
mkdir := func(name string) Result { return exec(t, root, Request{Op: OpMkdir, Path: name}) }
t.Run("makes the folder and hands it to the game uid", func(t *testing.T) {
var owned []string
prev := ownWritten
ownWritten = func(_ *os.Root, name string) error {
owned = append(owned, name)
return os.ErrPermission
}
defer func() { ownWritten = prev }()
if res := mkdir("config/sub/"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if info, err := os.Lstat(filepath.Join(root, "config", "sub")); err != nil || !info.IsDir() {
t.Fatalf("config/sub = %v, %v; want a folder", info, err)
}
if len(owned) != 1 || owned[0] != "config/sub" {
t.Fatalf("owned = %v, want [config/sub]", owned)
}
})
t.Run("an existing name is exists", func(t *testing.T) {
for _, name := range []string{"config", "server.properties"} {
if res := mkdir(name); res.Code != CodeExists {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeExists)
}
}
})
t.Run("a missing parent is not_found and is not made", func(t *testing.T) {
res := mkdir("plugins/Essentials")
if res.Code != CodeNotFound || res.Error != "folder plugins does not exist" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
t.Run("the root itself is bad_path", func(t *testing.T) {
for _, name := range []string{"", ".", "./", "config/.."} {
if res := mkdir(name); res.Code != CodeBadPath || res.Error != "a folder needs a name" {
t.Errorf("%q: result = %+v", name, res)
}
}
})
t.Run("an escape is bad_path and makes nothing outside", func(t *testing.T) {
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, name := range []string{"../outside/made", "escape-link/made", filepath.Join(outside, "made")} {
if res := mkdir(name); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(outside, "made"))
})
}
func TestRemove(t *testing.T) {
root, outside := worldRoot(t)
remove := func(name string) Result { return exec(t, root, Request{Op: OpDelete, Path: name}) }
t.Run("a file", func(t *testing.T) {
if res := remove("config/paper.yml"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a folder with everything in it", func(t *testing.T) {
deep := filepath.Join(root, "plugins", "Essentials")
if err := os.MkdirAll(deep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(deep, "config.yml"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if res := remove("plugins"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
// A link is removed itself. With a trailing slash the kernel would resolve
// it to the folder it names, whose contents would then go instead.
t.Run("a link, never what it points at", func(t *testing.T) {
keep := filepath.Join(root, "keep")
if err := os.MkdirAll(keep, 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(keep, "a.txt"), []byte("kept"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "keep", filepath.Join(root, "keep-link"))
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, name := range []string{"keep-link/", "escape-link"} {
if res := remove(name); res.Code != "" {
t.Fatalf("%s: result = %+v", name, res)
}
assertAbsent(t, filepath.Join(root, strings.TrimSuffix(name, "/")))
}
if got := mustRead(t, filepath.Join(keep, "a.txt")); got != "kept" {
t.Fatalf("keep/a.txt = %q", got)
}
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("the root itself is bad_path", func(t *testing.T) {
for _, name := range []string{"", ".", "./", "config/.."} {
if res := remove(name); res.Code != CodeBadPath {
t.Errorf("%q: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
mustRead(t, filepath.Join(root, "server.properties"))
})
t.Run("an escape is bad_path and deletes nothing outside", func(t *testing.T) {
symlink(t, outside, filepath.Join(root, "escape-dir"))
for _, name := range []string{"../outside/secret.txt", "escape-dir/secret.txt", "../outside"} {
if res := remove(name); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", name, res.Code, CodeBadPath)
}
}
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("a missing path is not_found", func(t *testing.T) {
if res := remove("absent.txt"); res.Code != CodeNotFound {
t.Fatalf("code = %q, want %q", res.Code, CodeNotFound)
}
})
}
func TestRename(t *testing.T) {
rename := func(t *testing.T, root, from, to string) Result {
return exec(t, root, Request{Op: OpRename, Path: from, To: to})
}
t.Run("a file moves", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "paper.yml"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "paper.yml")); got != "verbose: false\n" {
t.Fatalf("paper.yml = %q", got)
}
assertAbsent(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a folder moves with its contents", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.MkdirAll(filepath.Join(root, "plugins"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "plugins", "a.jar"), []byte("jar"), 0o644); err != nil {
t.Fatal(err)
}
if res := rename(t, root, "plugins/", "plugins-off"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if got := mustRead(t, filepath.Join(root, "plugins-off", "a.jar")); got != "jar" {
t.Fatalf("plugins-off/a.jar = %q", got)
}
assertAbsent(t, filepath.Join(root, "plugins"))
})
// A trailing slash would make the kernel act on what a link names; the link is
// what was asked for.
t.Run("a link moves itself, never what it names", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.MkdirAll(filepath.Join(root, "keep"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "keep", "a.jar"), []byte("jar"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "keep", filepath.Join(root, "keep-link"))
if res := rename(t, root, "keep-link/", "moved-link"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
if info, err := os.Lstat(filepath.Join(root, "moved-link")); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("moved-link = %v, %v; want the link", info, err)
}
if got := mustRead(t, filepath.Join(root, "keep", "a.jar")); got != "jar" {
t.Fatalf("keep/a.jar = %q", got)
}
assertAbsent(t, filepath.Join(root, "keep-link"))
})
t.Run("an existing destination is exists and both stay", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "server.properties"); res.Code != CodeExists {
t.Fatalf("code = %q, want %q", res.Code, CodeExists)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties = %q", got)
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
})
t.Run("a missing destination folder is not_found and is not made", func(t *testing.T) {
root, _ := worldRoot(t)
res := rename(t, root, "config/paper.yml", "disabled/paper.yml")
if res.Code != CodeNotFound || res.Error != "folder disabled does not exist" {
t.Fatalf("result = %+v", res)
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
assertAbsent(t, filepath.Join(root, "disabled"))
})
t.Run("a missing source is not_found", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "absent.txt", "b.txt"); res.Code != CodeNotFound {
t.Fatalf("code = %q, want %q", res.Code, CodeNotFound)
}
})
t.Run("the root is bad_path either way", func(t *testing.T) {
root, _ := worldRoot(t)
for _, tc := range [][2]string{
{".", "x"}, {"", "x"}, {"./", "x"},
{"config/paper.yml", "."}, {"config/paper.yml", "./"}, {"config/paper.yml", "config/.."},
} {
if res := rename(t, root, tc[0], tc[1]); res.Code != CodeBadPath {
t.Errorf("%q -> %q: code = %q, want %q", tc[0], tc[1], res.Code, CodeBadPath)
}
}
mustRead(t, filepath.Join(root, "config", "paper.yml"))
})
// read withholds these by name, so under another name they would come back
// whole. Every spelling of them, and every way of reaching them, is refused.
t.Run("the paths read guards cannot move", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.WriteFile(filepath.Join(root, "config", "paper-global.yml"), []byte("secret: k"), 0o644); err != nil {
t.Fatal(err)
}
symlink(t, "config", filepath.Join(root, "cfg-link"))
for _, from := range []string{
"server.properties", "./server.properties",
"config/paper-global.yml", "config//paper-global.yml",
"config", "config/", "./config",
"cfg-link/paper-global.yml",
} {
res := rename(t, root, from, "moved")
if res.Code != CodeBadPath || !strings.Contains(res.Error, "managed by felis") {
t.Errorf("%s: result = %+v, want the managed refusal", from, res)
}
}
assertAbsent(t, filepath.Join(root, "moved"))
mustRead(t, filepath.Join(root, "config", "paper-global.yml"))
mustRead(t, filepath.Join(root, "server.properties"))
})
// When the guarded name is itself a link, what it names is guarded too, and so
// is the link.
t.Run("the target of a guarded link cannot move", func(t *testing.T) {
root, _ := worldRoot(t)
if err := os.Rename(filepath.Join(root, "config"), filepath.Join(root, "real-config")); err != nil {
t.Fatal(err)
}
symlink(t, "real-config", filepath.Join(root, "config"))
if err := os.Rename(filepath.Join(root, "server.properties"), filepath.Join(root, "real.properties")); err != nil {
t.Fatal(err)
}
symlink(t, "real.properties", filepath.Join(root, "server.properties"))
// The links themselves too: under another name, a read would follow one to
// what it guards.
for _, from := range []string{"real-config", "real.properties", "config", "server.properties"} {
if res := rename(t, root, from, "moved"); res.Code != CodeBadPath {
t.Errorf("%s: code = %q, want %q", from, res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(root, "moved"))
})
t.Run("a neighbour of a guarded path still moves", func(t *testing.T) {
root, _ := worldRoot(t)
if res := rename(t, root, "config/paper.yml", "config/paper.yml.bak"); res.Code != "" {
t.Fatalf("result = %+v", res)
}
})
t.Run("an escape is bad_path either way", func(t *testing.T) {
root, outside := worldRoot(t)
symlink(t, outside, filepath.Join(root, "escape-link"))
for _, tc := range [][2]string{
{"../outside/secret.txt", "stolen.txt"},
{"escape-link/secret.txt", "stolen.txt"},
{"config/paper.yml", "../outside/planted.yml"},
{"config/paper.yml", "escape-link/planted.yml"},
} {
if res := rename(t, root, tc[0], tc[1]); res.Code != CodeBadPath {
t.Errorf("%s -> %s: code = %q, want %q", tc[0], tc[1], res.Code, CodeBadPath)
}
}
assertAbsent(t, filepath.Join(root, "stolen.txt"))
assertAbsent(t, filepath.Join(outside, "planted.yml"))
mustRead(t, filepath.Join(root, "config", "paper.yml"))
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
}
// fakeSource is an upload's bytes as the Job would fetch them.
type fakeSource struct {
body string
opened int
err error // returned by Open
readErr error // returned by the body once it runs out
}
func (s *fakeSource) upload(size int64, sum string) *Upload {
return &Upload{Size: size, SHA256: sum, Open: func() (io.ReadCloser, error) {
s.opened++
if s.err != nil {
return nil, s.err
}
var r io.Reader = strings.NewReader(s.body)
if s.readErr != nil {
r = io.MultiReader(r, errReader{s.readErr})
}
return io.NopCloser(r), nil
}}
}
type errReader struct{ err error }
func (e errReader) Read([]byte) (int, error) { return 0, e.err }
func TestUpload(t *testing.T) {
const jar = "PK\x03\x04 plugin bytes"
whole := func(s *fakeSource) *Upload { return s.upload(int64(len(s.body)), digest([]byte(s.body))) }
send := func(t *testing.T, root, name string, u *Upload, overwrite bool) (Result, error) {
t.Helper()
return Execute(root, Request{Op: OpUpload, Path: name, Upload: u, Overwrite: overwrite})
}
t.Run("lands the bytes as a new file", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "config/Geyser.jar", whole(src), false)
if err != nil || res.Code != "" {
t.Fatalf("result = %+v, %v", res, err)
}
if got := mustRead(t, filepath.Join(root, "config", "Geyser.jar")); got != jar {
t.Fatalf("content = %q", got)
}
info, _ := os.Stat(filepath.Join(root, "config", "Geyser.jar"))
if info.Mode().Perm() != 0o644 {
t.Fatalf("mode = %v, want 0644", info.Mode().Perm())
}
assertNoTemporaries(t, filepath.Join(root, "config"))
})
t.Run("an existing path is exists and the bytes are never fetched", func(t *testing.T) {
root, _ := worldRoot(t)
symlink(t, "config/elsewhere.jar", filepath.Join(root, "dangling.jar"))
for _, name := range []string{"server.properties", "dangling.jar"} {
src := &fakeSource{body: jar}
res, err := send(t, root, name, whole(src), false)
if err != nil || res.Code != CodeExists || src.opened != 0 {
t.Fatalf("%s: result = %+v, %v, opened %d; want exists and no fetch", name, res, err, src.opened)
}
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties = %q", got)
}
assertAbsent(t, filepath.Join(root, "config", "elsewhere.jar"))
})
t.Run("overwrite replaces the file and keeps its mode", func(t *testing.T) {
root, _ := worldRoot(t)
props := filepath.Join(root, "server.properties")
if err := os.Chmod(props, 0o600); err != nil {
t.Fatal(err)
}
res, err := send(t, root, "server.properties", whole(&fakeSource{body: jar}), true)
if err != nil || res.Code != "" {
t.Fatalf("result = %+v, %v", res, err)
}
if got := mustRead(t, props); got != jar {
t.Fatalf("content = %q", got)
}
if info, _ := os.Stat(props); info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600 kept", info.Mode().Perm())
}
})
t.Run("a folder is bad_path and the bytes are never fetched", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "config", whole(src), true)
if err != nil || res.Code != CodeBadPath || src.opened != 0 {
t.Fatalf("result = %+v, %v, opened %d", res, err, src.opened)
}
})
t.Run("over the cap is too_large and never fetched; at the cap is fetched", func(t *testing.T) {
root, _ := worldRoot(t)
src := &fakeSource{body: jar}
res, err := send(t, root, "big.jar", src.upload(MaxUploadBytes+1, ""), false)
if err != nil || res.Code != CodeTooLarge || src.opened != 0 {
t.Fatalf("result = %+v, %v, opened %d", res, err, src.opened)
}
// At the cap the size passes and the transfer starts; this source then
// comes up short, which is a broken transfer rather than a refusal.
if _, err := send(t, root, "big.jar", src.upload(MaxUploadBytes, ""), false); err == nil || src.opened != 1 {
t.Fatalf("at the cap: err = %v, opened %d; want a fetch", err, src.opened)
}
})
// Anything that says the bytes did not arrive intact is the Job failing, not a
// caller mistake, and whatever was at the path stays exactly as it was.
t.Run("a broken transfer is an error and changes nothing", func(t *testing.T) {
for name, u := range map[string]func(*fakeSource) *Upload{
"short": func(s *fakeSource) *Upload { return s.upload(int64(len(jar))+1, digest([]byte(jar))) },
"long": func(s *fakeSource) *Upload { return s.upload(int64(len(jar))-1, digest([]byte(jar[:len(jar)-1]))) },
"wrong sha256": func(s *fakeSource) *Upload { return s.upload(int64(len(jar)), digest([]byte("other"))) },
"open fails": func(s *fakeSource) *Upload { s.err = errors.New("connection refused"); return whole(s) },
"source breaks": func(s *fakeSource) *Upload { s.readErr = errors.New("connection reset"); return whole(s) },
// The source's own error must not read as the volume filling up.
"source ENOSPC": func(s *fakeSource) *Upload { s.readErr = syscall.ENOSPC; return whole(s) },
} {
t.Run(name, func(t *testing.T) {
root, _ := worldRoot(t)
res, err := send(t, root, "server.properties", u(&fakeSource{body: jar}), true)
var te *transferError
if !errors.As(err, &te) || res.Code != "" {
t.Fatalf("result = %+v, err = %v; want a transfer error", res, err)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
assertNoTemporaries(t, root)
})
}
})
t.Run("a full volume is no_space and changes nothing", func(t *testing.T) {
root, _ := worldRoot(t)
prev := syncWritten
syncWritten = func(*os.File) error { return syscall.ENOSPC }
defer func() { syncWritten = prev }()
res, err := send(t, root, "server.properties", whole(&fakeSource{body: jar}), true)
if err != nil || res.Code != CodeNoSpace {
t.Fatalf("result = %+v, %v; want no_space", res, err)
}
if got := mustRead(t, filepath.Join(root, "server.properties")); got != "motd=hello\n" {
t.Fatalf("server.properties became %q", got)
}
assertNoTemporaries(t, root)
})
t.Run("an escape is bad_path and never fetched", func(t *testing.T) {
root, outside := worldRoot(t)
symlink(t, outside, filepath.Join(root, "escape-link"))
symlink(t, "../../outside/secret.txt", filepath.Join(root, "config", "climb"))
for _, name := range []string{"../outside/x.jar", "escape-link/x.jar", "config/climb"} {
src := &fakeSource{body: jar}
res, err := send(t, root, name, whole(src), true)
if err != nil || res.Code != CodeBadPath || src.opened != 0 {
t.Errorf("%s: result = %+v, %v, opened %d", name, res, err, src.opened)
}
}
assertAbsent(t, filepath.Join(outside, "x.jar"))
if got := mustRead(t, filepath.Join(outside, "secret.txt")); got != "TOP-SECRET" {
t.Fatalf("outside/secret.txt = %q", got)
}
})
t.Run("no source is an error", func(t *testing.T) {
root, _ := worldRoot(t)
if _, err := send(t, root, "x.jar", nil, false); err == nil {
t.Fatal("an upload without a source must fail")
}
})
}
func TestExecuteRefusesAnUnknownOp(t *testing.T) {
root, _ := worldRoot(t)
if _, err := Execute(root, Request{Op: "chmod", Path: "server.properties"}); err == nil {
t.Fatal("an unknown op must fail")
}
}
+254
View File
@@ -0,0 +1,254 @@
package fileedit
import (
"crypto/rand"
"crypto/sha256"
"crypto/subtle"
"encoding/hex"
"errors"
"fmt"
"hash"
"io"
"os"
"sync"
"syscall"
)
// Stage holds uploads between the request that brought them and the Job that
// lands them. The bytes cannot ride the Job spec the way an edit does (etcd caps
// an object near 1.5 MiB, and execve one environment string at 128 KiB), and
// felis-api cannot mount the world volume, so felis-api keeps the upload on its
// own disk and serves it once, on its internal face, to the Job it created for it
// (cmd/felis files, fetchUpload).
//
// Each staged upload is opened by an unguessable id in the URL plus a token the
// Job carries in its environment. Only a digest of the token is kept, compared in
// constant time, and the first successful Open spends it: the Job never retries,
// so a second Open could only be someone else. The release func Put returns
// deletes the file once the Job has answered, whatever it answered.
//
// Nothing here outlives the process: the index is in memory, so Sweep empties
// Dir at startup of whatever a previous process left behind.
type Stage struct {
// Dir holds the staged files. cmd/felis puts it on the uploads volume, which
// has a real capacity; the pod's /tmp is the node's own disk.
Dir string
// MinFree is the share of Dir's filesystem an upload must leave free
// (DefaultStageMinFree when zero), so a burst of uploads cannot fill the disk
// the submission store shares.
MinFree float64
mu sync.Mutex
items map[string]*stagedFile
reserved int64
}
// DefaultStageMinFree matches the submission store's own floor
// (submit.DefaultUploadsMinFree): the two share the uploads volume.
const DefaultStageMinFree = 0.10
type stagedFile struct {
path string
tokenHash [sha256.Size]byte
size int64
used bool
}
// Staged is one upload on the stage: where the Job fetches it and what it must
// be.
type Staged struct {
ID string
Token string
SHA256 string
Size int64
}
var (
// ErrStageFull is an upload that would leave less than MinFree of the staging
// filesystem free, or that ran it out of space outright.
ErrStageFull = errors.New("fileedit: no room to stage the upload")
// ErrShortUpload is a body that ended, or broke, before its declared length.
ErrShortUpload = errors.New("fileedit: the upload ended before its declared length")
// ErrNotStaged is an Open with an unknown id, a wrong token, or a spent one.
// They are one error on purpose: the internal face answers all three the same.
ErrNotStaged = errors.New("fileedit: no such staged upload")
)
// statfs reports a filesystem's available and total bytes. A var so a test can
// stage against a disk of a chosen size.
var statfs = func(dir string) (avail, total uint64, err error) {
var st syscall.Statfs_t
if err := syscall.Statfs(dir, &st); err != nil {
return 0, 0, err
}
bsize := uint64(st.Bsize) // uint32 on darwin
return uint64(st.Bavail) * bsize, uint64(st.Blocks) * bsize, nil
}
// Sweep deletes everything under Dir. Call it once, before the first Put.
func (s *Stage) Sweep() error {
if err := os.RemoveAll(s.Dir); err != nil {
return fmt.Errorf("fileedit: clear the upload stage: %w", err)
}
return nil
}
// Put stages exactly size bytes from body and returns the handle a Job fetches
// it by, plus the func that deletes it. body must end right after size bytes (an
// HTTP body with that Content-Length does): Put reads to its end, which is also
// what tells the server the body is done.
func (s *Stage) Put(body io.Reader, size int64) (Staged, func(), error) {
if size < 0 {
return Staged{}, nil, fmt.Errorf("fileedit: an upload of %d bytes", size)
}
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
return Staged{}, nil, fmt.Errorf("fileedit: create the upload stage: %w", err)
}
if err := s.reserve(size); err != nil {
return Staged{}, nil, err
}
defer s.unreserve(size)
f, err := os.CreateTemp(s.Dir, "upload-*")
if err != nil {
return Staged{}, nil, fmt.Errorf("fileedit: stage the upload: %w", err)
}
h := sha256.New()
src := &bodyReader{r: body}
// One byte past size, so the read that finds the end happens here.
n, copyErr := io.Copy(io.MultiWriter(f, h), io.LimitReader(src, size+1))
closeErr := f.Close()
if err := stageFailure(src.err, copyErr, closeErr, n, size); err != nil {
os.Remove(f.Name())
return Staged{}, nil, err
}
st, tokenHash, err := newHandle(h, size)
if err != nil {
os.Remove(f.Name())
return Staged{}, nil, err
}
s.mu.Lock()
if s.items == nil {
s.items = map[string]*stagedFile{}
}
s.items[st.ID] = &stagedFile{path: f.Name(), tokenHash: tokenHash, size: size}
s.mu.Unlock()
release := func() {
s.mu.Lock()
delete(s.items, st.ID)
s.mu.Unlock()
os.Remove(f.Name())
}
return st, release, nil
}
// stageFailure decides what a finished copy means. The body's own error, or a
// body shorter than promised, is the caller's; a body longer than promised
// cannot come through net/http, which stops at Content-Length, but a direct
// caller could send one and it is refused all the same.
func stageFailure(readErr, copyErr, closeErr error, n, size int64) error {
switch {
case readErr != nil:
return fmt.Errorf("%w: %v", ErrShortUpload, readErr)
case copyErr == nil && n < size:
return fmt.Errorf("%w: got %d of %d bytes", ErrShortUpload, n, size)
case copyErr == nil && n > size:
return fmt.Errorf("fileedit: the upload is longer than its declared %d bytes", size)
}
err := copyErr
if err == nil {
err = closeErr
}
if err == nil {
return nil
}
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) {
return fmt.Errorf("%w: %v", ErrStageFull, err)
}
return fmt.Errorf("fileedit: stage the upload: %w", err)
}
// newHandle mints the id and token for a staged upload whose bytes h hashed.
func newHandle(h hash.Hash, size int64) (Staged, [sha256.Size]byte, error) {
var id [16]byte
var token [32]byte
if _, err := rand.Read(id[:]); err != nil {
return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload id: %w", err)
}
if _, err := rand.Read(token[:]); err != nil {
return Staged{}, [sha256.Size]byte{}, fmt.Errorf("fileedit: generate an upload token: %w", err)
}
st := Staged{
ID: hex.EncodeToString(id[:]),
Token: hex.EncodeToString(token[:]),
SHA256: hex.EncodeToString(h.Sum(nil)),
Size: size,
}
return st, sha256.Sum256([]byte(st.Token)), nil
}
// reserve admits an upload of size bytes if the disk keeps MinFree free after it
// and after every upload still being written. Those have not reached the disk
// yet, so statfs alone would let two of them through on room for one.
func (s *Stage) reserve(size int64) error {
avail, total, err := statfs(s.Dir)
if err != nil {
return fmt.Errorf("fileedit: measure the upload stage: %w", err)
}
minFree := s.MinFree
if minFree <= 0 {
minFree = DefaultStageMinFree
}
floor := uint64(float64(total) * minFree)
s.mu.Lock()
defer s.mu.Unlock()
need := uint64(s.reserved) + uint64(size)
if avail < need || avail-need < floor {
return fmt.Errorf("%w: %d MiB free of %d MiB, and staging %d MiB would leave less than %.0f%% free",
ErrStageFull, avail>>20, total>>20, need>>20, minFree*100)
}
s.reserved += size
return nil
}
func (s *Stage) unreserve(size int64) {
s.mu.Lock()
s.reserved -= size
s.mu.Unlock()
}
// Open spends a staged upload's token and returns its file and size. Any
// mismatch is ErrNotStaged.
func (s *Stage) Open(id, token string) (*os.File, int64, error) {
sum := sha256.Sum256([]byte(token))
s.mu.Lock()
it, ok := s.items[id]
if !ok || it.used || subtle.ConstantTimeCompare(sum[:], it.tokenHash[:]) != 1 {
s.mu.Unlock()
return nil, 0, ErrNotStaged
}
it.used = true
s.mu.Unlock()
f, err := os.Open(it.path)
if err != nil {
return nil, 0, fmt.Errorf("fileedit: open the staged upload: %w", err)
}
return f, it.size, nil
}
// bodyReader remembers the body's own read error, so Put can tell a client that
// went away from the disk filling up.
type bodyReader struct {
r io.Reader
err error
}
func (b *bodyReader) Read(p []byte) (int, error) {
n, err := b.r.Read(p)
if err != nil && err != io.EOF {
b.err = err
}
return n, err
}
+279
View File
@@ -0,0 +1,279 @@
package fileedit
import (
"errors"
"io"
"os"
"path/filepath"
"regexp"
"strings"
"syscall"
"testing"
)
// stubStatfs makes every staging disk report avail of total bytes free.
func stubStatfs(t *testing.T, avail, total uint64) {
t.Helper()
prev := statfs
statfs = func(string) (uint64, uint64, error) { return avail, total, nil }
t.Cleanup(func() { statfs = prev })
}
// roomyStage is a stage on a disk with room for anything a test stages.
func roomyStage(t *testing.T) *Stage {
t.Helper()
stubStatfs(t, 1<<40, 1<<41)
return &Stage{Dir: filepath.Join(t.TempDir(), "stage")}
}
func stagedNames(t *testing.T, s *Stage) []string {
t.Helper()
des, err := os.ReadDir(s.Dir)
if err != nil && !errors.Is(err, os.ErrNotExist) {
t.Fatal(err)
}
var names []string
for _, de := range des {
names = append(names, de.Name())
}
return names
}
var hexID = regexp.MustCompile(`^[0-9a-f]{32}$`)
var hexToken = regexp.MustCompile(`^[0-9a-f]{64}$`)
// TestStageOpensOnce: a staged upload opens once, for the token minted with it,
// and a wrong token does not spend it.
func TestStageOpensOnce(t *testing.T) {
s := roomyStage(t)
const body = "PK\x03\x04 staged"
st, release, err := s.Put(strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("Put: %v", err)
}
if !hexID.MatchString(st.ID) || !hexToken.MatchString(st.Token) ||
st.Size != int64(len(body)) || st.SHA256 != digest([]byte(body)) {
t.Fatalf("staged = %+v", st)
}
other, releaseOther, err := s.Put(strings.NewReader(body), int64(len(body)))
if err != nil {
t.Fatalf("Put: %v", err)
}
defer releaseOther()
if other.ID == st.ID || other.Token == st.Token {
t.Fatal("two uploads share an id or a token")
}
for name, try := range map[string][2]string{
"unknown id": {strings.Repeat("0", 32), st.Token},
"wrong token": {st.ID, strings.Repeat("0", 64)},
"another upload's token": {st.ID, other.Token},
"no token": {st.ID, ""},
} {
if f, _, err := s.Open(try[0], try[1]); !errors.Is(err, ErrNotStaged) {
if f != nil {
f.Close()
}
t.Fatalf("%s: err = %v, want ErrNotStaged", name, err)
}
}
f, size, err := s.Open(st.ID, st.Token)
if err != nil {
t.Fatalf("Open: %v", err)
}
got, _ := io.ReadAll(f)
f.Close()
if string(got) != body || size != int64(len(body)) {
t.Fatalf("opened %q (%d bytes), want %q", got, size, body)
}
if _, _, err := s.Open(st.ID, st.Token); !errors.Is(err, ErrNotStaged) {
t.Fatalf("second Open: err = %v, want ErrNotStaged", err)
}
release()
if names := stagedNames(t, s); len(names) != 1 {
t.Fatalf("after release: %v, want only the other upload", names)
}
if _, _, err := s.Open(other.ID, other.Token); err != nil {
t.Fatalf("releasing one upload spent another: %v", err)
}
}
// Staged uploads are other people's files, so neither the folder nor the file
// is readable by anyone but felis-api's own uid.
func TestStageIsPrivate(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(strings.NewReader("x"), 1)
if err != nil {
t.Fatalf("Put: %v", err)
}
defer release()
dir, err := os.Stat(s.Dir)
if err != nil || dir.Mode().Perm() != 0o700 {
t.Fatalf("stage folder = %v, %v; want 0700", dir.Mode().Perm(), err)
}
names := stagedNames(t, s)
file, err := os.Stat(filepath.Join(s.Dir, names[0]))
if err != nil || file.Mode().Perm() != 0o600 {
t.Fatalf("staged file = %v, %v; want 0600", file.Mode().Perm(), err)
}
}
// eofReader serves body and records whether it was read to its end.
type eofReader struct {
r io.Reader
hitEOF bool
}
func (e *eofReader) Read(p []byte) (int, error) {
n, err := e.r.Read(p)
if err == io.EOF {
e.hitEOF = true
}
return n, err
}
// Put reads the body to its end: net/http's body deadline is lifted only then
// (withBodyDeadline), and a request whose body was not finished would otherwise
// be cut off under the handler still landing it.
func TestStagePutReadsToTheEnd(t *testing.T) {
s := roomyStage(t)
body := &eofReader{r: strings.NewReader("abc")}
_, release, err := s.Put(body, 3)
if err != nil {
t.Fatalf("Put: %v", err)
}
defer release()
if !body.hitEOF {
t.Fatal("Put stopped at the declared size without reading the end of the body")
}
}
func TestStageRefusesABodyOfTheWrongLength(t *testing.T) {
for name, tc := range map[string]struct {
body io.Reader
size int64
short bool
}{
"ends early": {strings.NewReader("abc"), 5, true},
"breaks": {io.MultiReader(strings.NewReader("ab"), errReader{io.ErrUnexpectedEOF}), 5, true},
// The client's own failure is a short upload, whatever errno it carries.
"breaks with ENOSPC": {io.MultiReader(strings.NewReader("ab"), errReader{syscall.ENOSPC}), 5, true},
"runs long": {strings.NewReader("abcdef"), 3, false},
} {
t.Run(name, func(t *testing.T) {
s := roomyStage(t)
_, release, err := s.Put(tc.body, tc.size)
if err == nil {
release()
t.Fatal("Put accepted it")
}
if errors.Is(err, ErrShortUpload) != tc.short || errors.Is(err, ErrStageFull) {
t.Fatalf("err = %v, want short upload = %v", err, tc.short)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("left behind: %v", names)
}
})
}
if _, _, err := roomyStage(t).Put(strings.NewReader(""), -1); err == nil {
t.Fatal("a negative size was accepted")
}
}
// TestStageKeepsItsFloor: an upload is refused if it would leave less than
// MinFree of the disk free, counting uploads still arriving.
func TestStageKeepsItsFloor(t *testing.T) {
put := func(s *Stage, size int64) error {
_, release, err := s.Put(strings.NewReader(strings.Repeat("x", int(size))), size)
if err == nil {
release()
}
return err
}
t.Run("exactly at the floor is allowed, one byte past is not", func(t *testing.T) {
stubStatfs(t, 1000, 1200) // floor 600 at MinFree 0.5: room for 400
s := &Stage{Dir: t.TempDir(), MinFree: 0.5}
if err := put(s, 400); err != nil {
t.Fatalf("400 bytes: %v", err)
}
if err := put(s, 401); !errors.Is(err, ErrStageFull) {
t.Fatalf("401 bytes: err = %v, want ErrStageFull", err)
}
})
t.Run("an unset MinFree is the default", func(t *testing.T) {
stubStatfs(t, 1400, 10000) // floor 1000 at 10%: room for 400
s := &Stage{Dir: t.TempDir()}
if err := put(s, 400); err != nil {
t.Fatalf("400 bytes: %v", err)
}
if err := put(s, 401); !errors.Is(err, ErrStageFull) {
t.Fatalf("401 bytes: err = %v, want ErrStageFull", err)
}
})
t.Run("more than is free at all", func(t *testing.T) {
stubStatfs(t, 300, 1<<40)
s := &Stage{Dir: t.TempDir(), MinFree: 1e-12}
if err := put(s, 301); !errors.Is(err, ErrStageFull) {
t.Fatalf("err = %v, want ErrStageFull", err)
}
})
// statfs cannot see an upload still arriving, so the reservation is what keeps
// two of them from passing on room for one.
t.Run("an upload in flight holds its room", func(t *testing.T) {
stubStatfs(t, 1000, 1200) // room for 400
s := &Stage{Dir: t.TempDir(), MinFree: 0.5}
pr, pw := io.Pipe()
done := make(chan error, 1)
go func() {
_, release, err := s.Put(pr, 300)
if err == nil {
release()
}
done <- err
}()
// The write returns once Put is copying, which is after it reserved.
if _, err := pw.Write([]byte("x")); err != nil {
t.Fatal(err)
}
if err := put(s, 200); !errors.Is(err, ErrStageFull) {
t.Fatalf("second upload beside one in flight: err = %v, want ErrStageFull", err)
}
if _, err := pw.Write([]byte(strings.Repeat("x", 299))); err != nil {
t.Fatal(err)
}
pw.Close()
if err := <-done; err != nil {
t.Fatalf("the upload in flight: %v", err)
}
if err := put(s, 200); err != nil {
t.Fatalf("after the first finished: %v", err)
}
})
}
func TestStageSweep(t *testing.T) {
s := roomyStage(t)
if err := os.MkdirAll(s.Dir, 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(s.Dir, "upload-left-by-a-crash"), []byte("x"), 0o600); err != nil {
t.Fatal(err)
}
if err := s.Sweep(); err != nil {
t.Fatalf("Sweep: %v", err)
}
if names := stagedNames(t, s); len(names) != 0 {
t.Fatalf("after Sweep: %v", names)
}
if _, release, err := s.Put(strings.NewReader("x"), 1); err != nil {
t.Fatalf("Put after Sweep: %v", err)
} else {
release()
}
}
+13 -9
View File
@@ -54,8 +54,9 @@ const (
// their own copies; maintenance_test pins them against these).
LabelServer = "felis.lolicon.best/server"
LabelManagedBy = "app.kubernetes.io/managed-by"
// LabelFilesMode is the file-editor operation (list, read, write) a files Job
// performs. Only write holds the volume.
// LabelFilesMode is the file operation (list, read, write, mkdir, delete,
// rename, upload) a files Job performs. Every one but list and read holds the
// volume.
LabelFilesMode = "felis.lolicon.best/files-mode"
// LabelThenRestore marks a backup Job that is the safety snapshot in front of
@@ -90,14 +91,18 @@ const (
KindReap = "reap"
)
// FilesModeWrite is the LabelFilesMode value of a file write.
const FilesModeWrite = "write"
// The LabelFilesMode values of the two file operations that only look: they
// mount the world read-only, so they hold nothing.
const (
FilesModeList = "list"
FilesModeRead = "read"
)
// JobKind names the holder a Job represents, or reports false for a Job that
// holds nothing (a file read, a build, anything else in the namespace). A files
// Job without LabelFilesMode predates the label and is counted as a write: it can
// only be an old Job still inside its TTL, and over-counting it for that window
// is the safe side.
// Job holds unless it is a list or a read, so an operation this build does not
// know — and a Job without LabelFilesMode, which can only be an old one still
// inside its TTL — counts as a change: over-counting is the safe side.
func JobKind(j *batchv1.Job) (string, bool) {
switch j.Labels[LabelManagedBy] {
case "felis-restore":
@@ -105,8 +110,7 @@ func JobKind(j *batchv1.Job) (string, bool) {
case "felis-backup":
return KindBackup, true
case "felis-files":
mode, ok := j.Labels[LabelFilesMode]
if !ok || mode == FilesModeWrite {
if mode := j.Labels[LabelFilesMode]; mode != FilesModeList && mode != FilesModeRead {
return KindFileWrite, true
}
}
+15 -1
View File
@@ -51,8 +51,13 @@ func filesJob(t *testing.T, server, op string) batchv1.Job {
Image: "felis:1", WorldsRoot: "/data", Deadline: time.Minute, CPULimit: "500m",
MemLimit: "256Mi", TTLAfterFinished: time.Minute,
}
if op == fileedit.OpWrite {
switch op {
case fileedit.OpWrite:
p.Content = []byte("motd=hi\n")
case fileedit.OpRename:
p.To = "server.properties.bak"
case fileedit.OpUpload:
p.SourceURL, p.UploadToken = "http://felis-api-internal.felis.svc.cluster.local:8081/x", "t"
}
j, err := fileedit.FilesJob(p)
if err != nil {
@@ -78,6 +83,10 @@ func TestJobKindMatchesTheExecutors(t *testing.T) {
{"restore", restoreJob(t, "survival"), KindRestore, true},
{"backup", backupJob(t, "survival"), KindBackup, true},
{"file write", filesJob(t, "survival", fileedit.OpWrite), KindFileWrite, true},
{"file mkdir", filesJob(t, "survival", fileedit.OpMkdir), KindFileWrite, true},
{"file delete", filesJob(t, "survival", fileedit.OpDelete), KindFileWrite, true},
{"file rename", filesJob(t, "survival", fileedit.OpRename), KindFileWrite, true},
{"file upload", filesJob(t, "survival", fileedit.OpUpload), KindFileWrite, true},
{"file read", filesJob(t, "survival", fileedit.OpRead), "", false},
{"file list", filesJob(t, "survival", fileedit.OpList), "", false},
} {
@@ -97,6 +106,11 @@ func TestUnlabelledFilesJobCountsAsWrite(t *testing.T) {
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job from before the mode label = %q, %v; want file-write", kind, ok)
}
// An operation a later build adds holds too, until this build learns it.
j.Labels[LabelFilesMode] = "chmod"
if kind, ok := JobKind(&j); !ok || kind != KindFileWrite {
t.Fatalf("a files Job with an unknown mode = %q, %v; want file-write", kind, ok)
}
}
func TestHolderFromJobs(t *testing.T) {