feat(files): 文件管理可新建、建目录、删除、重命名和上传,写入内容拆成多个环境变量不再超内核单变量上限
This commit is contained in:
37 files changed
+5972
-416
No files matched your search
@@ -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
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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)
|
||||
}},
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user