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"},
}