fix(files): 配置文件改为同目录临时文件 fsync 后原子改名写入,读取返回内容哈希、保存带期望哈希冲突返回 409,磁盘满返回 507,面板提示载入最新或仍然覆盖

This commit is contained in:
Lemon-miaow committed 2026-09-25 03:56:30 +08:00
1 parent 074bd1783c
commit e1c325d594
17 files changed
+721 -131

No files matched your search

+34 -9
View File
@@ -4,6 +4,7 @@ import (
"context"
"errors"
"net/http"
"regexp"
"felis.lolicon.best/internal/apis/felis/v1alpha1"
"felis.lolicon.best/internal/fileedit"
@@ -30,12 +31,16 @@ import (
// mirrors how ImageBuilder uses build.Request/build.Image rather than restating a
// parallel type on this side of the seam.
//
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge for caller-fault
// failures, which writeFileEditError maps to 404 / 400 / 413.
// It returns fileedit.ErrNotFound / ErrBadPath / ErrTooLarge / ErrConflict /
// ErrNoSpace, which writeFileEditError maps to 404 / 400 / 413 / 409 / 507.
//
// Read and Write both return the file's SHA-256 (hex). Write's expect is the hash
// a client read the file at; when set, a file that changed since is refused with
// ErrConflict instead of being overwritten.
type FileEditor interface {
List(ctx context.Context, server, path string) (entries []fileedit.Entry, truncated bool, err error)
Read(ctx context.Context, server, path string) ([]byte, error)
Write(ctx context.Context, server, path string, content []byte) error
Read(ctx context.Context, server, path string) (content []byte, sha256 string, err error)
Write(ctx context.Context, server, path string, content []byte, expect string) (sha256 string, err error)
}
// writeFileRequest is the PUT /servers/{name}/file body. Content is []byte, so
@@ -49,8 +54,14 @@ type FileEditor interface {
// answering 200 — a client serialisation bug silently destroying the very config
// the caller opened this endpoint to repair. nil now means "the field was omitted"
// and is refused; an explicit "" is still a legitimate deliberate truncate.
//
// ExpectSHA256 is optional. The panel always sends the hash its read returned,
// so a save over a file someone else changed in the meantime answers 409
// file_changed; omitting it (a script, or "overwrite anyway") writes
// unconditionally.
type writeFileRequest struct {
Content *[]byte `json:"content"`
Content *[]byte `json:"content"`
ExpectSHA256 string `json:"expect_sha256,omitempty"`
}
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
@@ -98,12 +109,12 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
return
}
content, err := a.Files.Read(r.Context(), name, path)
content, sum, err := a.Files.Read(r.Context(), name, path)
if err != nil {
writeFileEditError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content})
writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
}
// handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's
@@ -149,6 +160,13 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
len(*body.Content), fileedit.MaxWriteBytes))
return
}
// The hash rides the Job's argv, so only its one legitimate shape is let
// through: 64 lowercase hex digits, exactly what a read returned.
if body.ExpectSHA256 != "" && !sha256Hex.MatchString(body.ExpectSHA256) {
writeError(w, r, newError(http.StatusBadRequest, "bad_request",
"expect_sha256 must be the 64-digit lowercase hex sha256 a read returned"))
return
}
// A write holds the world volume for its Job's lifetime (internal/maintenance);
// reads and listings do not, since a read-only mount cannot hurt a server
@@ -159,15 +177,18 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
}
defer release()
if err := a.Files.Write(r.Context(), name, path, *body.Content); err != nil {
sum, err := a.Files.Write(r.Context(), name, path, *body.Content, body.ExpectSHA256)
if err != nil {
writeFileEditError(w, r, err)
return
}
a.audit(r, "file.write", name+":"+path)
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written"})
writeJSON(w, http.StatusOK, map[string]any{"path": path, "status": "written", "sha256": sum})
}
var sha256Hex = regexp.MustCompile(`^[0-9a-f]{64}$`)
// authorizeFileOp is the shared front half of all three file handlers — the gate
// that decides whether this caller may touch this server's world at all. It
// mirrors the backup/restore gate step for step, because it is guarding the same
@@ -260,6 +281,10 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
writeError(w, r, newError(http.StatusBadRequest, "bad_path", "%s", err.Error()))
case errors.Is(err, fileedit.ErrTooLarge):
writeError(w, r, newError(http.StatusRequestEntityTooLarge, "too_large", "%s", err.Error()))
case errors.Is(err, fileedit.ErrConflict):
writeError(w, r, newError(http.StatusConflict, "file_changed", "%s", err.Error()))
case errors.Is(err, fileedit.ErrNoSpace):
writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly"))
+63 -7
View File
@@ -23,10 +23,12 @@ type fakeFileEditor struct {
gotServer string
gotPath string
gotContent []byte
gotExpect string
entries []fileedit.Entry
truncated bool
content []byte
sum string
}
func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedit.Entry, bool, error) {
@@ -35,18 +37,21 @@ func (f *fakeFileEditor) List(_ context.Context, server, path string) ([]fileedi
return f.entries, f.truncated, f.err
}
func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, error) {
func (f *fakeFileEditor) Read(_ context.Context, server, path string) ([]byte, string, error) {
f.calls++
f.gotServer, f.gotPath = server, path
return f.content, f.err
return f.content, f.sum, f.err
}
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte) error {
func (f *fakeFileEditor) Write(_ context.Context, server, path string, content []byte, expect string) (string, error) {
f.calls++
f.gotServer, f.gotPath, f.gotContent = server, path, content
return f.err
f.gotServer, f.gotPath, f.gotContent, f.gotExpect = server, path, content, expect
return f.sum, f.err
}
// testSum is a well-formed sha256 hex digest for the fake to hand out.
var testSum = strings.Repeat("a", 64)
// mkFiles builds an API whose "survival" server is STOPPED and owned by owner1,
// with a wired fakeFileEditor — the state in which every file operation is
// permitted, so each subtest changes exactly the one thing it is about.
@@ -310,9 +315,10 @@ func TestFileEditorHandlers(t *testing.T) {
}
})
t.Run("read returns base64 content", func(t *testing.T) {
t.Run("read returns base64 content and its hash", func(t *testing.T) {
api, _, _, files := mkFiles()
files.content = []byte("motd=hello\n")
files.sum = testSum
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "GET", "/api/v1/servers/survival/file?path=server.properties", "", nil)
@@ -322,11 +328,12 @@ func TestFileEditorHandlers(t *testing.T) {
var resp struct {
Path string `json:"path"`
Content []byte `json:"content"`
SHA256 string `json:"sha256"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v", err)
}
if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" {
if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum {
t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
}
})
@@ -361,6 +368,53 @@ func TestFileEditorHandlers(t *testing.T) {
}
})
t.Run("write passes the expected hash through and returns the new one", func(t *testing.T) {
api, _, _, files := mkFiles()
files.sum = strings.Repeat("b", 64)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
}
if files.gotExpect != testSum {
t.Fatalf("executor got expect %q, want %q", files.gotExpect, testSum)
}
var resp struct {
SHA256 string `json:"sha256"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil || resp.SHA256 != files.sum {
t.Fatalf("response sha256 = %q (%v), want %q", resp.SHA256, err, files.sum)
}
})
t.Run("a malformed expected hash -> 400 before the executor", func(t *testing.T) {
for _, bad := range []string{"abc", strings.Repeat("A", 64), strings.Repeat("a", 63) + " ", "--op=list"} {
api, _, _, files := mkFiles()
api.External = staticExternal{p: owner}
body, _ := json.Marshal(map[string]any{"content": []byte("hi"), "expect_sha256": bad})
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
string(body), jsonHeader)
if w.Code != http.StatusBadRequest || files.calls != 0 {
t.Fatalf("expect %q: code = %d calls = %d, want 400 and no Job", bad, w.Code, files.calls)
}
}
})
t.Run("a stale write -> 409 file_changed, not audited", func(t *testing.T) {
api, repo, _, files := mkFiles()
files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
if w.Code != http.StatusConflict || decodeErr(t, w) != "file_changed" {
t.Fatalf("code = %d body %s, want 409 file_changed", w.Code, w.Body.String())
}
if len(repo.audits) != 0 {
t.Fatalf("a refused write was audited: %+v", repo.audits)
}
})
t.Run("reads are not audited", func(t *testing.T) {
api, repo, _, _ := mkFiles()
api.External = staticExternal{p: owner}
@@ -457,6 +511,8 @@ func TestFileEditorErrorMapping(t *testing.T) {
{"escaping path", fmt.Errorf("%w: nope", fileedit.ErrBadPath), http.StatusBadRequest, "bad_path"},
{"missing file", fmt.Errorf("%w: nope", fileedit.ErrNotFound), http.StatusNotFound, "not_found"},
{"oversized file", fmt.Errorf("%w: nope", fileedit.ErrTooLarge), http.StatusRequestEntityTooLarge, "too_large"},
{"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"},
{"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
}
+29 -12
View File
@@ -64,6 +64,12 @@ var (
// ErrTooLarge is a read of a file over MaxReadBytes or a write over
// MaxWriteBytes.
ErrTooLarge = errors.New("fileedit: file is too large for the editor")
// ErrConflict is a write whose expected hash no longer matches: the file
// changed after the caller read it.
ErrConflict = errors.New("fileedit: file changed since it was read")
// ErrNoSpace is a write the world volume had no room for; the file is
// unchanged.
ErrNoSpace = errors.New("fileedit: the world volume is full")
)
// Runner is the cluster-side half of one file operation: render and create the
@@ -181,7 +187,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, OpList, path, nil, "")
if err != nil {
return nil, false, err
}
@@ -193,25 +199,31 @@ func (e *Editor) List(ctx context.Context, server, path string) ([]Entry, bool,
return res.Entries, res.Truncated, nil
}
// Read returns a file's bytes, resolved under the server's world root.
func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, error) {
res, err := e.run(ctx, server, OpRead, path, nil)
// 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, "")
if err != nil {
return nil, err
return nil, "", err
}
// A zero-length file unmarshals Content as nil, which is a legitimate result,
// not an error — normalise so the caller never has to distinguish nil from empty.
if res.Content == nil {
res.Content = []byte{}
}
return res.Content, nil
return res.Content, res.SHA256, nil
}
// Write replaces a file's contents, creating it if absent (but never creating
// parent directories — see the write helper in exec.go).
func (e *Editor) Write(ctx context.Context, server, path string, content []byte) error {
_, err := e.run(ctx, server, OpWrite, path, content)
return err
// 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)
if err != nil {
return "", err
}
return res.SHA256, nil
}
// run is the shared body of all three operations: mint an op id, render the
@@ -221,7 +233,7 @@ func (e *Editor) Write(ctx context.Context, server, path string, content []byte)
// That is not redundancy for its own sake: an oversized write would otherwise be
// 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) (Result, error) {
func (e *Editor) run(ctx context.Context, server, op, path string, content []byte, expect string) (Result, error) {
if op == OpWrite && len(content) > MaxWriteBytes {
return Result{}, fmt.Errorf("%w: content is %d bytes, the limit is %d",
ErrTooLarge, len(content), MaxWriteBytes)
@@ -246,6 +258,7 @@ func (e *Editor) run(ctx context.Context, server, op, path string, content []byt
Op: op,
Path: path,
Content: content,
Expect: expect,
WorldPVC: naming.WorldPVCName(server),
Namespace: cfg.Namespace,
ServiceAccount: cfg.ServiceAccount,
@@ -284,6 +297,10 @@ func resultError(res Result) error {
return fmt.Errorf("%w: %s", ErrBadPath, res.Error)
case CodeTooLarge:
return fmt.Errorf("%w: %s", ErrTooLarge, res.Error)
case CodeConflict:
return fmt.Errorf("%w: %s", ErrConflict, res.Error)
case CodeNoSpace:
return fmt.Errorf("%w: %s", ErrNoSpace, res.Error)
default:
return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error)
}
+18 -15
View File
@@ -63,15 +63,15 @@ func TestEditorRendersParams(t *testing.T) {
})
t.Run("read", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n")})}
r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n"), SHA256: "abc"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
got, err := e.Read(context.Background(), "survival", "server.properties")
got, sum, err := e.Read(context.Background(), "survival", "server.properties")
if err != nil {
t.Fatalf("Read: %v", err)
}
if string(got) != "motd=hi\n" {
t.Fatalf("content = %q", got)
if string(got) != "motd=hi\n" || sum != "abc" {
t.Fatalf("content = %q sha256 = %q", got, sum)
}
if r.got[0].Op != OpRead || r.got[0].Path != "server.properties" {
t.Fatalf("params = %+v", r.got[0])
@@ -79,14 +79,15 @@ func TestEditorRendersParams(t *testing.T) {
})
t.Run("write", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{})}
r := &fakeRunner{payload: mustPayload(t, Result{SHA256: "new"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if err := e.Write(context.Background(), "survival", "ops.json", []byte("[]")); err != nil {
sum, err := e.Write(context.Background(), "survival", "ops.json", []byte("[]"), "old")
if err != nil {
t.Fatalf("Write: %v", err)
}
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" {
t.Fatalf("params = %+v", r.got[0])
if r.got[0].Op != OpWrite || string(r.got[0].Content) != "[]" || r.got[0].Expect != "old" || sum != "new" {
t.Fatalf("params = %+v, sha256 = %q", r.got[0], sum)
}
})
}
@@ -100,7 +101,7 @@ func TestEditorMintsAFreshOpID(t *testing.T) {
e := &Editor{Runner: r, Config: Config{Image: "img"}}
for range 3 {
if _, err := e.Read(context.Background(), "survival", "x"); err != nil {
if _, _, err := e.Read(context.Background(), "survival", "x"); err != nil {
t.Fatalf("Read: %v", err)
}
}
@@ -129,12 +130,14 @@ func TestEditorMapsResultCodes(t *testing.T) {
{"missing file", CodeNotFound, ErrNotFound},
{"escaping path", CodeBadPath, ErrBadPath},
{"oversized", CodeTooLarge, ErrTooLarge},
{"changed since read", CodeConflict, ErrConflict},
{"volume full", CodeNoSpace, ErrNoSpace},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{Code: tc.code, Error: "detail here"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
_, err := e.Read(context.Background(), "survival", "x")
_, _, err := e.Read(context.Background(), "survival", "x")
if !errors.Is(err, tc.want) {
t.Fatalf("err = %v, want %v", err, tc.want)
}
@@ -144,7 +147,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
t.Run("an unknown code still fails", func(t *testing.T) {
r := &fakeRunner{payload: mustPayload(t, Result{Code: "from_the_future", Error: "?"})}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
t.Fatal("an unrecognised failure code must not read as success")
}
})
@@ -152,7 +155,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
t.Run("a malformed payload is an error, not an empty success", func(t *testing.T) {
r := &fakeRunner{payload: []byte("not json at all")}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
t.Fatal("a malformed result must fail")
}
})
@@ -160,7 +163,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
t.Run("a runner failure propagates", func(t *testing.T) {
r := &fakeRunner{err: errors.New("pod never scheduled")}
e := &Editor{Runner: r, Config: Config{Image: "img"}}
if _, err := e.Read(context.Background(), "survival", "x"); err == nil {
if _, _, err := e.Read(context.Background(), "survival", "x"); err == nil {
t.Fatal("a runner error must propagate")
}
})
@@ -173,7 +176,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), "")
if !errors.Is(err, ErrTooLarge) {
t.Fatalf("err = %v, want ErrTooLarge", err)
}
@@ -197,7 +200,7 @@ func TestEditorNormalisesEmptyResults(t *testing.T) {
t.Fatal("an empty directory must list as [], not nil")
}
content, err := e.Read(context.Background(), "survival", "empty.txt")
content, _, err := e.Read(context.Background(), "survival", "empty.txt")
if err != nil {
t.Fatalf("Read: %v", err)
}
+207 -25
View File
@@ -2,6 +2,9 @@ package fileedit
import (
"bytes"
"crypto/rand"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
@@ -9,6 +12,8 @@ import (
"io/fs"
"os"
"path"
"strings"
"syscall"
"time"
"felis.lolicon.best/internal/naming"
@@ -46,6 +51,14 @@ const (
CodeBadPath = "bad_path" // escapes the world root, is absolute, or is otherwise unopenable
CodeNotFound = "not_found" // resolves inside the root but nothing is there
CodeTooLarge = "too_large" // the file exceeds MaxReadBytes
// CodeConflict is a write whose expected hash no longer matches the file: it
// changed (or vanished) after the caller read it, so saving would silently
// discard someone else's edit.
CodeConflict = "conflict"
// CodeNoSpace is a write the volume had no room for. The file is unchanged
// (the write is atomic), so this is the caller's volume being full rather
// than a bad request.
CodeNoSpace = "no_space"
)
// ResultPrefix marks the single stdout line carrying the JSON Result. The Job's
@@ -119,6 +132,11 @@ type Result struct {
// client renders "showing first N" rather than silently implying the directory
// is smaller than it is.
Truncated bool `json:"truncated,omitempty"`
// SHA256 is the hex digest of the file's on-disk bytes: after a read, the file
// as read (before any redaction); after a write, the bytes written; on a
// conflict, the file as it is now. A client hands it back as the expected hash
// of its next write (see write).
SHA256 string `json:"sha256,omitempty"`
}
// Execute performs op on the file named by path, resolved inside root, and returns
@@ -145,7 +163,10 @@ type Result struct {
// "<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.
func Execute(root, op, path string, content []byte) (Result, error) {
//
// expect is a write's precondition: when non-empty, the write lands only if the
// file's current SHA-256 (hex) equals it. It is ignored by list and read.
func Execute(root, op, path string, content []byte, expect string) (Result, error) {
r, err := os.OpenRoot(root)
if err != nil {
// The world mount itself is unopenable: infrastructure, not caller fault.
@@ -163,7 +184,7 @@ func Execute(root, op, path string, content []byte) (Result, error) {
case OpRead:
return read(r, path), nil
case OpWrite:
return write(r, path, content), nil
return write(r, path, content, expect), nil
default:
return Result{}, fmt.Errorf("unknown op %q", op)
}
@@ -268,7 +289,7 @@ func read(r *os.Root, name string) Result {
if err != nil {
return failure(err, name)
}
return Result{Content: redactSecretProps(name, b)}
return Result{Content: redactSecretProps(name, b), SHA256: digest(b)}
}
// propsPath is the server's main config file, and rconPasswordKey the one line in
@@ -314,16 +335,34 @@ func redactSecretProps(name string, content []byte) []byte {
return bytes.Join(lines, []byte("\n"))
}
// write replaces a file's contents. It truncates rather than appends, and it does
// NOT create parent directories: every 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.
// write replaces a file's contents. It does NOT create parent directories: every
// 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.
//
// O_CREATE is still allowed so a config file the server has not yet generated can
// be authored. os.Root applies the same containment to the create as to an open,
// so a symlink at the target pointing outside the root is refused rather than
// followed — the classic "write through a planted symlink" escape.
func write(r *os.Root, path string, content []byte) Result {
// 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.
//
// expect, when set, is the SHA-256 the caller read the file at (Result.SHA256 of
// its read). A file that has changed since — another manager saved it, or the
// server rewrote it on its last run — is refused with CodeConflict instead of
// being overwritten, which is how two people editing the same file find out.
// The world lock (internal/maintenance) already serialises writes, so the check
// and the rename cannot interleave with another write.
//
// os.Root applies the same containment to every step. A symlink at the target is
// followed only while it stays inside the root (resolveLink), so a planted link
// to a file outside is refused and the rename replaces the file the link names,
// never the link itself.
func write(r *os.Root, name string, content []byte, expect string) Result {
if len(content) > MaxWriteBytes {
// 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
@@ -331,29 +370,172 @@ func write(r *os.Root, path string, content []byte) Result {
return Result{Code: CodeTooLarge, Error: fmt.Sprintf(
"content is %d bytes; the editor writes at most %d", len(content), MaxWriteBytes)}
}
f, err := r.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0o644)
target, res := resolveLink(r, name)
if res.Code != "" {
return res
}
mode := fs.FileMode(0o644)
info, err := r.Lstat(target)
switch {
case err == nil && info.IsDir():
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is a directory, not a file", name)}
case err == nil && !info.Mode().IsRegular():
return Result{Code: CodeBadPath, Error: fmt.Sprintf("%s is not a regular file", name)}
case err == nil:
mode = info.Mode().Perm()
case !errors.Is(err, fs.ErrNotExist):
return failure(err, name)
}
if expect != "" {
if res := checkUnchanged(r, name, target, expect); res.Code != "" {
return res
}
}
var suffix [6]byte
if _, err := rand.Read(suffix[:]); err != nil {
return Result{Code: CodeBadPath, Error: fmt.Sprintf("generate a temporary name: %v", err)}
}
tmp := path.Join(path.Dir(target), "."+path.Base(target)+".felis-edit-"+hex.EncodeToString(suffix[:]))
f, err := r.OpenFile(tmp, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode)
if err != nil {
return failure(err, path)
return writeFailure(err, name)
}
renamed := false
defer func() {
if !renamed {
_ = r.Remove(tmp)
}
}()
// Chmod explicitly: the create mode passed through the umask, and a file the
// owner had at 0664 or 0600 should come back the same.
if err := f.Chmod(mode); err != nil {
f.Close()
return writeFailure(err, name)
}
if _, err := f.Write(content); err != nil {
f.Close()
return failure(err, path)
return writeFailure(err, name)
}
// Sync before the rename, or a crash could leave the new name pointing at
// blocks that never reached the disk. Close is where a buffered-write error
// surfaces, so its error is honoured rather than deferred-and-dropped.
if err := syncWritten(f); err != nil {
f.Close()
return writeFailure(err, name)
}
// Close is where a buffered-write error surfaces, so its error is honoured
// rather than deferred-and-dropped: reporting success on a write that did not
// land would leave the caller believing a broken config was fixed.
if err := f.Close(); err != nil {
return failure(err, path)
return writeFailure(err, name)
}
// The Job runs as root, so the file it just created is root's. The server runs
// as the game uid and could read it but never rewrite it — a config the panel
// authored that Paper then fails to save. Best effort: the server's
// prepare-data initContainer re-owns anything left behind on its next start.
_ = ownWritten(r, tmp)
if err := r.Rename(tmp, target); err != nil {
return writeFailure(err, name)
}
renamed = true
// The rename lives in the directory; sync it so the new entry survives a crash
// too. Best effort: the content has landed and reporting failure would lie.
if d, err := r.Open(path.Dir(target)); err == nil {
_ = d.Sync()
d.Close()
}
return Result{SHA256: digest(content)}
}
// writeFailure is failure for the steps that move bytes, where a full volume is
// the likely cause and deserves its own answer: the file is still whole, and the
// fix is to free space, not to change the path.
func writeFailure(err error, name string) Result {
if errors.Is(err, syscall.ENOSPC) || errors.Is(err, syscall.EDQUOT) {
return Result{Code: CodeNoSpace, Error: fmt.Sprintf(
"the server's volume is full; %s was left unchanged", name)}
}
return failure(err, name)
}
// maxLinkHops bounds resolveLink, matching the kernel's own loop limit in spirit:
// a link cycle is a bad path, not a hang.
const maxLinkHops = 8
// resolveLink follows symlinks at the final component of name and returns the
// path of the file they lead to, relative to the root. An absolute link target is
// refused (os.Root would refuse it anyway); a relative one is resolved against
// the link's directory and must stay inside the root, which the next Lstat
// through os.Root enforces.
func resolveLink(r *os.Root, name string) (string, Result) {
cur := name
for range maxLinkHops {
info, err := r.Lstat(cur)
if err != nil || info.Mode()&fs.ModeSymlink == 0 {
// Missing (a new file) or not a link: the path names itself. Any other
// Lstat error surfaces from the caller's own Lstat of the same path.
return cur, Result{}
}
dest, err := r.Readlink(cur)
if err != nil {
return "", failure(err, name)
}
if path.IsAbs(dest) {
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
"%s is a symbolic link to %s, outside the server's files", name, dest)}
}
cur = path.Join(path.Dir(cur), dest)
if cur == ".." || strings.HasPrefix(cur, "../") {
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf(
"%s is a symbolic link leading outside the server's files", name)}
}
}
return "", Result{Code: CodeBadPath, Error: fmt.Sprintf("%s: too many levels of symbolic links", name)}
}
// checkUnchanged compares the file's current digest with expect. A file larger
// than MaxReadBytes cannot be the one the caller read, so it is a conflict without
// hashing it.
func checkUnchanged(r *os.Root, name, target, expect string) Result {
conflict := func(now, why string) Result {
return Result{Code: CodeConflict, SHA256: now, Error: fmt.Sprintf(
"%s %s since it was opened; reload it, or save again to overwrite", name, why)}
}
f, err := r.Open(target)
if errors.Is(err, fs.ErrNotExist) {
return conflict("", "was deleted")
}
if err != nil {
return failure(err, name)
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return failure(err, name)
}
if info.Size() > MaxReadBytes {
return conflict("", "has changed")
}
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, MaxReadBytes+1)); err != nil {
return failure(err, name)
}
if now := hex.EncodeToString(h.Sum(nil)); now != expect {
return conflict(now, "has changed")
}
// The Job runs as root, so a file it just created is root's. The server runs as
// the game uid and could read it (0644) but never rewrite it — a config the
// panel authored that Paper then fails to save. Best effort: the content has
// landed and reporting failure would lie, and the server's prepare-data
// initContainer re-owns anything left behind on its next start anyway.
_ = ownWritten(r, path)
return Result{}
}
// digest is the hex SHA-256 carried in Result.SHA256.
func digest(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
// syncWritten flushes the temporary sibling. A var so a test can fail it the way
// a full disk would.
var syncWritten = func(f *os.File) error { return f.Sync() }
// ownWritten hands a written file to the game uid. os.Root.Chown follows a symlink
// only within the root, so this can never re-own a file outside the mount. A var so
// tests, which cannot chown, can observe the call.
+153 -22
View File
@@ -6,6 +6,7 @@ import (
"os"
"path/filepath"
"strings"
"syscall"
"testing"
)
@@ -75,7 +76,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 := Execute(root, OpRead, v.path, nil, "")
if err != nil {
t.Fatalf("Execute returned an infrastructure error, want a contained refusal: %v", err)
}
@@ -91,7 +92,7 @@ func TestExecuteContainment(t *testing.T) {
// The write side must be contained by the same invariant: a planted symlink
// 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 := Execute(root, OpWrite, "planted.txt", []byte("pwned"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -108,7 +109,7 @@ func TestExecuteContainment(t *testing.T) {
})
t.Run("write escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"))
res, err := Execute(root, OpWrite, "../outside/new.txt", []byte("pwned"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -121,7 +122,7 @@ func TestExecuteContainment(t *testing.T) {
})
t.Run("list escaping by traversal is refused", func(t *testing.T) {
res, err := Execute(root, OpList, "../outside", nil)
res, err := Execute(root, OpList, "../outside", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -138,7 +139,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 := Execute(root, OpList, "", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -161,7 +162,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("list a subdirectory", func(t *testing.T) {
res, err := Execute(root, OpList, "config", nil)
res, err := Execute(root, OpList, "config", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -171,7 +172,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 := Execute(root, OpRead, "server.properties", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -181,7 +182,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("write replaces content, then reads back", func(t *testing.T) {
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=changed\n")); err != nil || res.Code != "" {
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=changed\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write failed: %v / %+v", err, res)
}
b, err := os.ReadFile(filepath.Join(root, "server.properties"))
@@ -201,13 +202,13 @@ func TestExecuteHappyPath(t *testing.T) {
return os.ErrPermission // a test runner cannot chown; the write must still succeed
}
defer func() { ownWritten = prev }()
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]")); err != nil || res.Code != "" {
if res, err := Execute(root, OpWrite, "ops.json", []byte("[]"), ""); err != nil || res.Code != "" {
t.Fatalf("creating a new file should succeed: %v / %+v", err, res)
}
if len(owned) != 1 || owned[0] != "ops.json" {
t.Errorf("written file handed to the game uid = %v, want [ops.json]", owned)
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 := Execute(root, OpWrite, "nope/deep.txt", []byte("x"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -217,7 +218,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("missing file reads as not_found", func(t *testing.T) {
res, err := Execute(root, OpRead, "absent.txt", nil)
res, err := Execute(root, OpRead, "absent.txt", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -227,7 +228,7 @@ func TestExecuteHappyPath(t *testing.T) {
})
t.Run("reading a directory is bad_path, not a garbled read", func(t *testing.T) {
res, err := Execute(root, OpRead, "config", nil)
res, err := Execute(root, OpRead, "config", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -237,7 +238,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 := Execute(root, OpWrite, "big.txt", make([]byte, MaxWriteBytes+1), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -250,7 +251,7 @@ func TestExecuteHappyPath(t *testing.T) {
if err := os.WriteFile(filepath.Join(root, "huge.bin"), make([]byte, MaxReadBytes+1), 0o644); err != nil {
t.Fatalf("write huge: %v", err)
}
res, err := Execute(root, OpRead, "huge.bin", nil)
res, err := Execute(root, OpRead, "huge.bin", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -271,7 +272,7 @@ func TestReadIsBinarySafe(t *testing.T) {
t.Fatalf("write raw: %v", err)
}
res, err := Execute(root, OpRead, "raw.bin", nil)
res, err := Execute(root, OpRead, "raw.bin", nil, "")
if err != nil || res.Code != "" {
t.Fatalf("read failed: %v / %+v", err, res)
}
@@ -332,7 +333,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
"config/../config/paper-global.yml",
"config/./paper-global.yml",
} {
res, err := Execute(root, OpRead, spelling, nil)
res, err := Execute(root, OpRead, spelling, nil, "")
if err != nil {
t.Fatalf("%s: Execute: %v", spelling, err)
}
@@ -347,7 +348,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// The denial is READ-only and exact: a neighbouring file in the same directory
// stays readable, or the guard would have broken ordinary config repair.
res, err := Execute(root, OpRead, "config/paper.yml", nil)
res, err := Execute(root, OpRead, "config/paper.yml", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -357,7 +358,7 @@ func TestReadRefusesTheForwardingSecret(t *testing.T) {
// Writing it is still allowed: it leaks nothing, and the lobby entrypoint
// rewrites the file whole on every boot regardless.
res, err = Execute(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"))
res, err = Execute(root, OpWrite, "config/paper-global.yml", []byte("proxies: {}\n"), "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -386,7 +387,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
t.Fatalf("write nested server.properties: %v", err)
}
res, err := Execute(root, OpRead, "server.properties", nil)
res, err := Execute(root, OpRead, "server.properties", nil, "")
if err != nil {
t.Fatalf("Execute: %v", err)
}
@@ -405,7 +406,7 @@ func TestReadRedactsRconPassword(t *testing.T) {
}
}
nested, err := Execute(root, OpRead, "plugins/server.properties", nil)
nested, err := Execute(root, OpRead, "plugins/server.properties", nil, "")
if err != nil {
t.Fatalf("Execute nested: %v", err)
}
@@ -413,3 +414,133 @@ func TestReadRedactsRconPassword(t *testing.T) {
t.Fatalf("a nested server.properties was redacted; only the world root's is the real one:\n%s", nested.Content)
}
}
// TestWriteIsAtomic is the durability contract: a write that fails part-way leaves
// the original file byte-for-byte intact and no stray sibling behind, and a write
// that succeeds keeps the file's mode.
func TestWriteIsAtomic(t *testing.T) {
root, _ := worldRoot(t)
props := filepath.Join(root, "server.properties")
t.Run("a failed flush leaves the old file and no temporary", func(t *testing.T) {
prev := syncWritten
syncWritten = func(*os.File) error { return syscall.ENOSPC }
defer func() { syncWritten = prev }()
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=half"), "")
if err != nil || res.Code != CodeNoSpace {
t.Fatalf("Execute = %+v, %v; want no_space", res, err)
}
if b, _ := os.ReadFile(props); string(b) != "motd=hello\n" {
t.Fatalf("original became %q after a failed write", b)
}
assertNoTemporaries(t, root)
})
t.Run("the file keeps its mode", func(t *testing.T) {
if err := os.Chmod(props, 0o600); err != nil {
t.Fatal(err)
}
if res, err := Execute(root, OpWrite, "server.properties", []byte("motd=x\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res)
}
info, err := os.Stat(props)
if err != nil {
t.Fatal(err)
}
if info.Mode().Perm() != 0o600 {
t.Fatalf("mode = %v, want 0600 kept", info.Mode().Perm())
}
assertNoTemporaries(t, root)
})
t.Run("a link inside the root is written through, not replaced", func(t *testing.T) {
if err := os.Symlink("config/paper.yml", filepath.Join(root, "paper-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
if res, err := Execute(root, OpWrite, "paper-link.yml", []byte("verbose: true\n"), ""); err != nil || res.Code != "" {
t.Fatalf("write: %v / %+v", err, res)
}
if b, _ := os.ReadFile(filepath.Join(root, "config", "paper.yml")); string(b) != "verbose: true\n" {
t.Fatalf("link target = %q, want the new content", b)
}
if info, err := os.Lstat(filepath.Join(root, "paper-link.yml")); err != nil || info.Mode()&os.ModeSymlink == 0 {
t.Fatalf("the link itself was replaced: %v %v", info, err)
}
})
t.Run("a relative link climbing out of the root is refused", func(t *testing.T) {
if err := os.Symlink("../../outside/secret.txt", filepath.Join(root, "config", "climb")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
res, err := Execute(root, OpWrite, "config/climb", []byte("pwned"), "")
if err != nil || res.Code != CodeBadPath {
t.Fatalf("Execute = %+v, %v; want bad_path", res, err)
}
})
}
// TestWriteDetectsConcurrentChange: a save carrying the hash its read returned
// lands only while the file is still what was read.
func TestWriteDetectsConcurrentChange(t *testing.T) {
root, _ := worldRoot(t)
props := filepath.Join(root, "server.properties")
if err := os.WriteFile(props, []byte("motd=hello\nrcon.password=hunter2\n"), 0o644); err != nil {
t.Fatal(err)
}
read, err := Execute(root, OpRead, "server.properties", nil, "")
if err != nil || read.Code != "" || len(read.SHA256) != 64 {
t.Fatalf("read = %+v, %v; want content and a sha256", read, err)
}
// The hash is of the file on disk, not the redacted copy handed out, or a save
// of an unchanged server.properties would always conflict.
if bytes.Contains(read.Content, []byte("hunter2")) {
t.Fatal("rcon password was not redacted")
}
// Someone else saves in between.
if err := os.WriteFile(props, []byte("motd=theirs\n"), 0o644); err != nil {
t.Fatal(err)
}
res, err := Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), read.SHA256)
if err != nil || res.Code != CodeConflict {
t.Fatalf("stale write = %+v, %v; want a conflict", res, err)
}
if b, _ := os.ReadFile(props); string(b) != "motd=theirs\n" {
t.Fatalf("stale write overwrote the other edit: %q", b)
}
if res.SHA256 != digest([]byte("motd=theirs\n")) {
t.Fatalf("conflict sha256 = %q, want the file's current hash", res.SHA256)
}
// With the current hash the save lands and reports the new one.
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != "" || res.SHA256 != digest([]byte("motd=mine\n")) {
t.Fatalf("fresh write = %+v, %v", res, err)
}
// A file deleted since it was read is a conflict too, never a silent re-create.
if err := os.Remove(props); err != nil {
t.Fatal(err)
}
res, err = Execute(root, OpWrite, "server.properties", []byte("motd=mine\n"), res.SHA256)
if err != nil || res.Code != CodeConflict {
t.Fatalf("write over a deleted file = %+v, %v; want a conflict", res, err)
}
if _, err := os.Stat(props); err == nil {
t.Fatal("a conditional write re-created a deleted file")
}
}
func assertNoTemporaries(t *testing.T, dir string) {
t.Helper()
des, err := os.ReadDir(dir)
if err != nil {
t.Fatal(err)
}
for _, de := range des {
if strings.Contains(de.Name(), ".felis-edit-") {
t.Fatalf("temporary %s left behind", de.Name())
}
}
}
+18 -9
View File
@@ -41,10 +41,13 @@ type JobParams struct {
Server string
// OpID is the per-invocation identifier that both names the Job and labels its
// Pod. See Editor.run for why every invocation gets a fresh one.
OpID string
Op string
Path string
Content []byte // OpWrite only
OpID string
Op string
Path string
Content []byte // OpWrite only
// Expect is a write's precondition hash (see Execute); empty writes
// unconditionally.
Expect string
WorldPVC string
Namespace string
@@ -152,15 +155,21 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
// code-review one.
readOnlyWorld := p.Op != OpWrite
args := []string{
"--op", p.Op,
"--path", p.Path,
"--worlds-root", p.WorldsRoot,
}
// The expected hash is a digest of content the caller already holds, not a
// secret, so it rides argv; only the content itself needs the env channel.
if p.Op == OpWrite && p.Expect != "" {
args = append(args, "--expect-sha256", p.Expect)
}
container := corev1.Container{
Name: containerName,
Image: p.Image,
Command: []string{felisBinaryPath, "files"},
Args: []string{
"--op", p.Op,
"--path", p.Path,
"--worlds-root", p.WorldsRoot,
},
Args: args,
VolumeMounts: []corev1.VolumeMount{
{Name: worldVolume, MountPath: p.WorldsRoot, ReadOnly: readOnlyWorld},
},
+26
View File
@@ -148,6 +148,32 @@ func TestFilesJobIsolation(t *testing.T) {
})
}
// TestFilesJobExpectArg: a write's precondition hash reaches the entrypoint as a
// flag, and only a write carries one.
func TestFilesJobExpectArg(t *testing.T) {
sum := strings.Repeat("a", 64)
for _, tc := range []struct {
op string
expect string
want bool
}{
{OpWrite, sum, true},
{OpWrite, "", false},
{OpRead, sum, false},
} {
p := testParams(tc.op)
p.Expect = tc.expect
job, err := FilesJob(p)
if err != nil {
t.Fatalf("FilesJob: %v", err)
}
args := strings.Join(job.Spec.Template.Spec.Containers[0].Args, " ")
if got := strings.Contains(args, "--expect-sha256 "+sum); got != tc.want {
t.Fatalf("op %s expect %q: args %q, want flag present = %v", tc.op, tc.expect, args, tc.want)
}
}
}
// TestFilesJobWorldMountIsReadOnlyExceptForWrite pins the guarantee that only a
// write can mutate a world. For list and read the kernel refuses the write, not
// merely the code — a defence that survives a bug in the entrypoint.