From 2b9cd1869c7fb8ec85cc8ed1a9424215922751df Mon Sep 17 00:00:00 2001 From: Lemon-miaow Date: Tue, 29 Sep 2026 01:43:37 +0800 Subject: [PATCH] =?UTF-8?q?fix(files):=20=E7=BC=96=E8=BE=91=E5=99=A8?= =?UTF-8?q?=E4=BF=9D=E5=AD=98=E5=92=8C=E6=89=93=E5=BC=80=E4=B9=9F=E9=80=90?= =?UTF-8?q?=E8=B7=B3=E6=A0=B8=E5=AF=B9=20SHA-256=EF=BC=8C=E9=80=94?= =?UTF-8?q?=E4=B8=AD=E5=8F=98=E4=BA=86=E7=9A=84=E5=86=85=E5=AE=B9=E4=B8=8D?= =?UTF-8?q?=E5=86=99=E7=9B=98=E4=B9=9F=E4=B8=8D=E6=89=93=E5=BC=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- cmd/felis/files.go | 10 ++- cmd/felis/files_test.go | 36 ++++++++- docs/openapi.yaml | 32 +++++++- internal/api/handlers_files.go | 40 +++++++++- internal/api/handlers_files_test.go | 85 ++++++++++++++++++---- internal/api/handlers_maintenance_test.go | 2 +- internal/fileedit/editor.go | 11 +++ internal/fileedit/editor_test.go | 30 +++++++- internal/fileedit/exec.go | 32 ++++++-- internal/fileedit/exec_test.go | 33 +++++++++ internal/fileedit/jobspec.go | 3 + internal/fileedit/jobspec_test.go | 11 ++- panel/src/i18n/resources/en-US/errors.json | 5 +- panel/src/i18n/resources/zh-CN/errors.json | 5 +- panel/src/lib/api.test.ts | 27 +++++-- panel/src/lib/api.ts | 37 ++++++++-- panel/src/lib/digest.ts | 10 +++ panel/src/lib/openapi.gen.ts | 17 ++++- 18 files changed, 369 insertions(+), 57 deletions(-) diff --git a/cmd/felis/files.go b/cmd/felis/files.go index 6ae49e6..38802e4 100644 --- a/cmd/felis/files.go +++ b/cmd/felis/files.go @@ -47,7 +47,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { to := fs.String("to", "", "rename only: the destination path") sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from") size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have") - sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have") + sum := fs.String("sha256", "", "write and upload: the SHA-256 (hex) the content or the fetched file must have") overwrite := fs.Bool("overwrite", false, "upload and unzip: replace files already there") if err := fs.Parse(args); err != nil { return 2 @@ -70,12 +70,18 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int { // channel that must be a valid string. switch *op { case fileedit.OpWrite: + // The content's SHA-256 comes with it, so bytes that changed on the way + // to this Job are refused rather than written (Request.ContentSHA256). + if *sum == "" { + fmt.Fprintln(stderr, "felis files: a write needs --sha256") + return 2 + } content, err := fileedit.ContentFromEnv(os.LookupEnv) if err != nil { fmt.Fprintf(stderr, "felis files: %v\n", err) return 2 } - req.Content = content + req.Content, req.ContentSHA256 = content, *sum case fileedit.OpUpload: token := os.Getenv(fileedit.UploadTokenEnv) if *sourceURL == "" || token == "" { diff --git a/cmd/felis/files_test.go b/cmd/felis/files_test.go index b27c0e9..1bd6073 100644 --- a/cmd/felis/files_test.go +++ b/cmd/felis/files_test.go @@ -238,10 +238,11 @@ func TestCmdFilesUpload(t *testing.T) { func TestCmdFilesWrite(t *testing.T) { root := t.TempDir() - args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root} + content := []byte("[]\r\n") + sum := sha256.Sum256(content) + args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])} t.Run("reassembles the content parts", func(t *testing.T) { - content := []byte("[]\r\n") t.Setenv(fileedit.ContentPartsEnv, "1") t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content)) var stdout, stderr bytes.Buffer @@ -261,13 +262,42 @@ func TestCmdFilesWrite(t *testing.T) { t.Setenv(fileedit.ContentPartsEnv, "2") t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x"))) var stdout, stderr bytes.Buffer - if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 { + if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 2 { t.Fatalf("exit %d, want 2", code) } if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) { t.Fatalf("an incomplete spec wrote a file: %v", err) } }) + + // Without the content's SHA-256 the Job could not tell bytes changed on the + // way from the bytes felis-api sent. + t.Run("a write without its SHA-256 exits 2 and writes nothing", func(t *testing.T) { + t.Setenv(fileedit.ContentPartsEnv, "1") + t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content)) + var stdout, stderr bytes.Buffer + if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 { + t.Fatalf("exit %d, want 2", code) + } + if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) { + t.Fatalf("a write without its SHA-256 wrote a file: %v", err) + } + }) + + t.Run("content that changed on the way is a result and writes nothing", func(t *testing.T) { + t.Setenv(fileedit.ContentPartsEnv, "1") + t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("[]\n"))) + var stdout, stderr bytes.Buffer + if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 0 { + t.Fatalf("exit %d, stderr %q", code, stderr.String()) + } + if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeDigestMismatch { + t.Fatalf("result = %+v, want %s", res, fileedit.CodeDigestMismatch) + } + if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) { + t.Fatalf("changed content wrote a file: %v", err) + } + }) } // A caller-fault outcome is a successful run carrying a code, so felis-api can diff --git a/docs/openapi.yaml b/docs/openapi.yaml index e11cf06..9c84e11 100644 --- a/docs/openapi.yaml +++ b/docs/openapi.yaml @@ -4615,7 +4615,7 @@ paths: application/json: schema: type: object - required: [path, content, sha256] + required: [path, content, sha256, content_sha256] properties: path: { type: string } content: { type: string, format: byte, description: Base64-encoded file bytes. } @@ -4625,6 +4625,13 @@ paths: description: >- SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. + content_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: >- + SHA-256 of the decoded content as sent (after any redaction). A + client that gets content hashing otherwise got it damaged on the + way, and reads it again. '400': description: Missing path, invalid server name, or a path that escapes the world root. content: @@ -4649,6 +4656,13 @@ paths: content: application/json: schema: { $ref: '#/components/schemas/Error' } + '502': + description: >- + The file's bytes do not hash to the digest the file Job sent with them + (read_damaged): they changed on the way to felis-api. Read it again. + content: + application/json: + schema: { $ref: '#/components/schemas/Error' } '503': $ref: '#/components/responses/ServiceUnavailable' '504': @@ -4671,7 +4685,10 @@ paths: root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; - otherwise 409 file_changed. Audited as file.write. + otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: + content that hashes otherwise changed on the way and is refused (400 + digest_mismatch) before a Job starts, and the Job checks the bytes it received + the same way before writing. Audited as file.write. x-felis-face: [external] x-felis-tier: app security: [{ sessionCookie: [] }] @@ -4688,9 +4705,16 @@ paths: application/json: schema: type: object - required: [content] + required: [content, content_sha256] properties: content: { type: string, format: byte, description: Base64-encoded file bytes. } + content_sha256: + type: string + pattern: '^[0-9a-f]{64}$' + description: >- + The SHA-256 (lowercase hex) of the decoded content. Absent is 400 + digest_required, malformed 400 bad_digest, and content that does not + hash to it 400 digest_mismatch; nothing is written. expect_sha256: type: string pattern: '^[0-9a-f]{64}$' @@ -4717,7 +4741,7 @@ paths: status: { type: string, const: written } sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes written. } '400': - description: Missing path, malformed body, invalid server name, or a path that escapes the world root. + description: Missing path, malformed body, invalid server name, or a path that escapes the world root (bad_request, bad_path), or content that came without its SHA-256 (digest_required), with a malformed one (bad_digest), or changed on the way (digest_mismatch). content: application/json: schema: { $ref: '#/components/schemas/Error' } diff --git a/internal/api/handlers_files.go b/internal/api/handlers_files.go index d5c1611..517e773 100644 --- a/internal/api/handlers_files.go +++ b/internal/api/handlers_files.go @@ -4,6 +4,7 @@ import ( "context" "crypto/sha256" "encoding/base64" + "encoding/hex" "errors" "io" "net/http" @@ -78,10 +79,16 @@ type FileEditor interface { // nothing is at the path yet (409 file_exists otherwise), so it can never // truncate a file the caller did not know was there. The two cannot be combined — // one says the file exists, the other that it must not. +// +// ContentSHA256 is required: the SHA-256 (hex) of the decoded content, which +// the caller computes over the bytes it means to write. Content that hashes +// otherwise changed on the way and is refused (400 digest_mismatch) before a Job +// starts; the Job checks the bytes it received the same way before it writes. type writeFileRequest struct { - Content *[]byte `json:"content"` - ExpectSHA256 string `json:"expect_sha256,omitempty"` - CreateOnly bool `json:"create_only,omitempty"` + Content *[]byte `json:"content"` + ContentSHA256 string `json:"content_sha256"` + ExpectSHA256 string `json:"expect_sha256,omitempty"` + CreateOnly bool `json:"create_only,omitempty"` } // handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's @@ -145,7 +152,12 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) { if content == nil { content = []byte{} // an empty file is "", never null } - writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum}) + // content_sha256 is of the bytes as sent (sha256 is of the file on disk, + // before any redaction), so the panel can tell a damaged read from the file. + got := sha256.Sum256(content) + writeJSON(w, http.StatusOK, map[string]any{ + "path": path, "content": content, "sha256": sum, "content_sha256": hex.EncodeToString(got[:]), + }) } // handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's @@ -201,6 +213,20 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) { "create_only and expect_sha256 cannot be combined")) return } + switch sum := sha256.Sum256(*body.Content); { + case body.ContentSHA256 == "": + writeError(w, r, newError(http.StatusBadRequest, "digest_required", + "send the SHA-256 of the content as content_sha256")) + return + case !sha256Hex.MatchString(body.ContentSHA256): + writeError(w, r, newError(http.StatusBadRequest, "bad_digest", + "content_sha256 must be the 64-digit lowercase hex SHA-256 of the content")) + return + case hex.EncodeToString(sum[:]) != body.ContentSHA256: + writeError(w, r, newError(http.StatusBadRequest, "digest_mismatch", + "the content that arrived does not hash to content_sha256, so it was changed on the way; send it again")) + return + } // A write holds the world volume for its Job's lifetime (internal/maintenance); // reads and listings do not. A read-only mount cannot hurt a server starting @@ -633,6 +659,12 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) { writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error())) case errors.Is(err, fileedit.ErrExists): writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error())) + case errors.Is(err, fileedit.ErrDigestMismatch): + // A write's content reached its Job changed; nothing was written. + writeError(w, r, newError(http.StatusBadRequest, "digest_mismatch", "%s", err.Error())) + case errors.Is(err, fileedit.ErrReadDamaged): + writeError(w, r, newError(http.StatusBadGateway, "read_damaged", + "the file's bytes changed on their way from the file Job; read it again")) case errors.Is(err, context.DeadlineExceeded): writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout", "the file operation did not finish in time; retry shortly")) diff --git a/internal/api/handlers_files_test.go b/internal/api/handlers_files_test.go index 9e82cb0..a485dec 100644 --- a/internal/api/handlers_files_test.go +++ b/internal/api/handlers_files_test.go @@ -180,7 +180,7 @@ func TestFileEditorStoppedGate(t *testing.T) { }{ {"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, - {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, + {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, @@ -237,7 +237,7 @@ func TestFileEditorWorldVolumeGate(t *testing.T) { }{ {"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, - {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, + {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, @@ -278,7 +278,7 @@ func TestFileEditorAuthorization(t *testing.T) { }{ {"list", "GET", "/api/v1/servers/survival/files", ""}, {"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, - {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`}, + {"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, @@ -436,14 +436,17 @@ func TestFileEditorHandlers(t *testing.T) { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } var resp struct { - Path string `json:"path"` - Content []byte `json:"content"` - SHA256 string `json:"sha256"` + Path string `json:"path"` + Content []byte `json:"content"` + SHA256 string `json:"sha256"` + Content256 string `json:"content_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" || resp.SHA256 != testSum { + // content_sha256 is of the bytes sent, so the panel can check what arrived. + if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum || + resp.Content256 != hexSum([]byte("motd=hello\n")) { t.Fatalf("unexpected response %+v (%q)", resp, resp.Content) } }) @@ -465,7 +468,7 @@ func TestFileEditorHandlers(t *testing.T) { api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":"bW90ZD1jaGFuZ2VkCg=="}`, jsonHeader) + `{"content":"bW90ZD1jaGFuZ2VkCg==","content_sha256":"`+hexSum([]byte("motd=changed\n"))+`"}`, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } @@ -483,7 +486,7 @@ func TestFileEditorHandlers(t *testing.T) { files.sum = strings.Repeat("b", 64) api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader) + `{"content":"aGk=","content_sha256":"`+hiSum+`","expect_sha256":"`+testSum+`"}`, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) } @@ -516,7 +519,7 @@ func TestFileEditorHandlers(t *testing.T) { files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict) api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader) + `{"content":"aGk=","content_sha256":"`+hiSum+`","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()) } @@ -577,7 +580,7 @@ func TestFileEditorHandlers(t *testing.T) { api, _, _, files := mkFiles(t) api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":""}`, jsonHeader) + `{"content":"","content_sha256":"`+hexSum(nil)+`"}`, jsonHeader) if w.Code != http.StatusOK { t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) } @@ -590,7 +593,8 @@ func TestFileEditorHandlers(t *testing.T) { t.Run("a write at exactly the limit is allowed", func(t *testing.T) { api, _, _, files := mkFiles(t) api.External = staticExternal{p: owner} - body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))}) + content := make([]byte, fileedit.MaxWriteBytes) + body, err := json.Marshal(writeFileRequest{Content: &content, ContentSHA256: hexSum(content)}) if err != nil { t.Fatalf("marshal: %v", err) } @@ -738,12 +742,12 @@ func TestFileManagerHandlers(t *testing.T) { api, _, _, files := mkFiles(t) api.External = staticExternal{p: owner} w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml", - `{"content":"","create_only":true}`, jsonHeader) + `{"content":"","content_sha256":"`+hexSum(nil)+`","create_only":true}`, jsonHeader) if w.Code != http.StatusOK || !files.gotCreateOnly || files.gotExpect != "" { t.Fatalf("code = %d, createOnly = %v, expect = %q (%s)", w.Code, files.gotCreateOnly, files.gotExpect, w.Body.String()) } w = do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":"aGk="}`, jsonHeader) + `{"content":"aGk=","content_sha256":"`+hiSum+`"}`, jsonHeader) if w.Code != http.StatusOK || files.gotCreateOnly { t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, files.gotCreateOnly) } @@ -766,6 +770,58 @@ func contentDigestOf(body string) string { return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":" } +// hexSum is the content_sha256 a client sends with a write of b; hiSum is that +// of "hi" (aGk=). +func hexSum(b []byte) string { + sum := sha256.Sum256(b) + return hex.EncodeToString(sum[:]) +} + +var hiSum = hexSum([]byte("hi")) + +// A write carries the SHA-256 of its content: one without it, with a malformed +// one, or whose content hashes otherwise is refused before a Job starts, and a +// Job that found the bytes it received changed answers the same way. None of +// them is audited. +func TestWriteFileChecksTheContentDigest(t *testing.T) { + owner := &Principal{UserID: "owner1", Email: "owner1@example.net", Role: "user"} + for _, tc := range []struct { + name string + body string + want string + }{ + {"without a digest", `{"content":"aGk="}`, "digest_required"}, + {"a malformed digest", `{"content":"aGk=","content_sha256":"` + strings.ToUpper(hiSum) + `"}`, "bad_digest"}, + {"changed on the way", `{"content":"aGo=","content_sha256":"` + hiSum + `"}`, "digest_mismatch"}, + } { + t.Run(tc.name, func(t *testing.T) { + api, repo, _, files := mkFiles(t) + api.External = staticExternal{p: owner} + w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", tc.body, jsonHeader) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != tc.want { + t.Fatalf("code = %d body %s, want 400 %s", w.Code, w.Body.String(), tc.want) + } + if files.calls != 0 || len(repo.audits) != 0 { + t.Fatalf("calls = %d audits = %+v, want no Job and no audit", files.calls, repo.audits) + } + }) + } + + t.Run("changed on the way to the Job", func(t *testing.T) { + api, repo, _, files := mkFiles(t) + files.err = fmt.Errorf("%w: the content hashes to 00 and was sent as 01", fileedit.ErrDigestMismatch) + api.External = staticExternal{p: owner} + w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", + `{"content":"aGk=","content_sha256":"`+hiSum+`"}`, jsonHeader) + if w.Code != http.StatusBadRequest || decodeErr(t, w) != "digest_mismatch" { + t.Fatalf("code = %d body %s, want 400 digest_mismatch", w.Code, w.Body.String()) + } + if files.calls != 1 || len(repo.audits) != 0 { + t.Fatalf("calls = %d audits = %+v, want the one Job and no audit", files.calls, repo.audits) + } + }) +} + // doUpload sends an upload whose Content-Length is declared, not measured, the // way a client that streams or lies would send it. Its Content-Digest is that // of the body. @@ -1063,6 +1119,7 @@ func TestFileEditorErrorMapping(t *testing.T) { {"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"}, {"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"}, {"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"}, + {"changed on the way from the Job", fmt.Errorf("%w: nope", fileedit.ErrReadDamaged), http.StatusBadGateway, "read_damaged"}, {"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"}, } diff --git a/internal/api/handlers_maintenance_test.go b/internal/api/handlers_maintenance_test.go index 7c01066..1dd32cf 100644 --- a/internal/api/handlers_maintenance_test.go +++ b/internal/api/handlers_maintenance_test.go @@ -107,7 +107,7 @@ func maintenanceOps(t *testing.T) (*API, *fakeCluster, []maintenanceOp) { {"backup", maintenance.KindBackup, "POST", "/api/v1/servers/survival/backup", "", func() int { return backuper.calls }}, {"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties", - `{"content":"aGk="}`, func() int { return files.calls }}, + `{"content":"aGk=","content_sha256":"` + hiSum + `"}`, func() int { return files.calls }}, {"file mkdir", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", "", func() int { return files.calls }}, {"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar", diff --git a/internal/fileedit/editor.go b/internal/fileedit/editor.go index 38fca7a..adde9cd 100644 --- a/internal/fileedit/editor.go +++ b/internal/fileedit/editor.go @@ -77,6 +77,9 @@ var ( // ErrExists is a create, mkdir, rename or upload whose target is already // there. ErrExists = errors.New("fileedit: the target already exists") + // ErrReadDamaged is a read whose bytes do not hash to the digest the Job + // sent with them: they changed on the way to felis-api. + ErrReadDamaged = errors.New("fileedit: the file's bytes changed on their way from the file Job") ) // Runner is the cluster-side half of a file operation. Run renders and creates @@ -243,11 +246,17 @@ func (e *Editor) List(ctx context.Context, server, path string) (Listing, error) // 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. +// Bytes that do not hash to the digest the Job computed over what it sent +// (Result.ContentSHA256) are ErrReadDamaged: an editor that saves back a +// damaged read would write the damage. func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) { res, err := e.run(ctx, server, JobParams{Op: OpRead, Path: path}) if err != nil { return nil, "", err } + if got := digest(res.Content); got != res.ContentSHA256 { + return nil, "", fmt.Errorf("%w: they hash to %s, sent as %q", ErrReadDamaged, got, res.ContentSHA256) + } // 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 { @@ -457,6 +466,8 @@ func resultError(res Result) error { return fmt.Errorf("%w: %s", ErrNoSpace, res.Error) case CodeExists: return fmt.Errorf("%w: %s", ErrExists, res.Error) + case CodeDigestMismatch: + return fmt.Errorf("%w: %s", ErrDigestMismatch, res.Error) default: return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error) } diff --git a/internal/fileedit/editor_test.go b/internal/fileedit/editor_test.go index 1df4e28..94c75f7 100644 --- a/internal/fileedit/editor_test.go +++ b/internal/fileedit/editor_test.go @@ -2,6 +2,7 @@ package fileedit import ( "context" + "encoding/hex" "encoding/json" "errors" "testing" @@ -79,7 +80,7 @@ func TestEditorRendersParams(t *testing.T) { }) t.Run("read", func(t *testing.T) { - r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n"), SHA256: "abc"})} + r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=hi\n"), SHA256: "abc", ContentSHA256: hex.EncodeToString(sumOf("motd=hi\n"))})} e := &Editor{Runner: r, Config: Config{Image: "img"}} got, sum, err := e.Read(context.Background(), "survival", "server.properties") @@ -162,7 +163,7 @@ func TestEditorRendersParams(t *testing.T) { // every time. If it ever cached one, two operations would collide on a name // felis-api has no permission to delete. func TestEditorMintsAFreshOpID(t *testing.T) { - r := &fakeRunner{payload: mustPayload(t, Result{})} + r := &fakeRunner{payload: mustPayload(t, Result{ContentSHA256: hex.EncodeToString(sumOf(""))})} e := &Editor{Runner: r, Config: Config{Image: "img"}} for range 3 { @@ -198,6 +199,7 @@ func TestEditorMapsResultCodes(t *testing.T) { {"changed since read", CodeConflict, ErrConflict}, {"volume full", CodeNoSpace, ErrNoSpace}, {"already there", CodeExists, ErrExists}, + {"changed on the way", CodeDigestMismatch, ErrDigestMismatch}, } for _, tc := range cases { t.Run(tc.name, func(t *testing.T) { @@ -251,11 +253,33 @@ func TestEditorRefusesOversizedWriteBeforeTheCluster(t *testing.T) { } } +// A read whose bytes do not hash to the digest the Job sent with them changed +// on the way, and none of them is handed on: an editor saving a damaged read +// would write the damage back. +func TestEditorReadRefusesBytesChangedOnTheWay(t *testing.T) { + for _, tc := range []struct { + name string + sent string + }{ + {"hashed otherwise", hex.EncodeToString(sumOf("motd=hi\n"))}, + {"with no digest", ""}, + } { + t.Run(tc.name, func(t *testing.T) { + r := &fakeRunner{payload: mustPayload(t, Result{Content: []byte("motd=ho\n"), SHA256: "abc", ContentSHA256: tc.sent})} + e := &Editor{Runner: r, Config: Config{Image: "img"}} + got, sum, err := e.Read(context.Background(), "survival", "server.properties") + if !errors.Is(err, ErrReadDamaged) || got != nil || sum != "" { + t.Fatalf("Read = %q, %q, %v; want nothing and ErrReadDamaged", got, sum, err) + } + }) + } +} + // TestEditorNormalisesEmptyResults pins that "nothing there" is a success, not a // nil surprise: an empty directory lists as [] and a zero-length file reads as // empty bytes, so no caller has to distinguish nil from empty. func TestEditorNormalisesEmptyResults(t *testing.T) { - r := &fakeRunner{payload: mustPayload(t, Result{})} + r := &fakeRunner{payload: mustPayload(t, Result{ContentSHA256: hex.EncodeToString(sumOf(""))})} e := &Editor{Runner: r, Config: Config{Image: "img"}} ls, err := e.List(context.Background(), "survival", "empty") diff --git a/internal/fileedit/exec.go b/internal/fileedit/exec.go index 8afc37d..c288782 100644 --- a/internal/fileedit/exec.go +++ b/internal/fileedit/exec.go @@ -84,6 +84,10 @@ const ( // there. None of them replaces anything unless told to (an upload's // Overwrite), so a name collision is reported rather than resolved. CodeExists = "exists" + // CodeDigestMismatch is a write whose bytes do not hash to the SHA-256 + // felis-api computed over them (Request.ContentSHA256): they changed on the + // way to the Job, and nothing was written. + CodeDigestMismatch = "digest_mismatch" ) // ResultPrefix marks the single stdout line carrying the JSON Result. The Job's @@ -186,6 +190,10 @@ type Result struct { // 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"` + // ContentSHA256 is, after a read, the hex digest of Content as handed out + // (after any redaction), so felis-api can tell the bytes it got from the + // bytes this Job sent (Editor.Read). + ContentSHA256 string `json:"content_sha256,omitempty"` // Conflicts lists, relative to the root and sorted, the existing files an // unzip would replace: the first of them, up to MaxConflicts and 8 KiB of @@ -213,9 +221,11 @@ type Request struct { 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 + // it. ContentSHA256, when set, is the SHA-256 (hex) felis-api computed over + // Content; bytes that hash otherwise are not written (CodeDigestMismatch). + Content []byte + Expect string + ContentSHA256 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. @@ -295,7 +305,7 @@ func Execute(root string, req Request) (Result, error) { case OpRead: return read(r, path), nil case OpWrite: - return write(r, path, req.Content, req.Expect, req.CreateOnly), nil + return write(r, path, req.Content, req.ContentSHA256, req.Expect, req.CreateOnly), nil case OpMkdir: return mkdir(r, path), nil case OpDelete: @@ -432,7 +442,7 @@ func read(r *os.Root, name string) Result { if redact { content = RedactProps(b) } - return Result{Content: content, SHA256: digest(b)} + return Result{Content: content, SHA256: digest(b), ContentSHA256: digest(content)} } // propsPath is the server's main config file, and rconPasswordKey the one line in @@ -492,7 +502,17 @@ 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. -func write(r *os.Root, name string, content []byte, expect string, createOnly bool) Result { +// +// sum, when set, is the SHA-256 felis-api computed over content before handing +// it to the Job: content that hashes otherwise changed on the way, and is +// refused with CodeDigestMismatch before anything is touched. +func write(r *os.Root, name string, content []byte, sum, expect string, createOnly bool) Result { + if sum != "" { + if got := digest(content); got != sum { + return Result{Code: CodeDigestMismatch, Error: fmt.Sprintf( + "the content hashes to %s and was sent as %s; nothing was written", got, sum)} + } + } 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 diff --git a/internal/fileedit/exec_test.go b/internal/fileedit/exec_test.go index c37a7f7..7242492 100644 --- a/internal/fileedit/exec_test.go +++ b/internal/fileedit/exec_test.go @@ -435,6 +435,11 @@ func TestReadRedactsRconPassword(t *testing.T) { if sum := sha256.Sum256([]byte(props)); res.SHA256 != hex.EncodeToString(sum[:]) { t.Fatalf("sha256 = %s, want the hash of the file as stored", res.SHA256) } + // The content digest is of the copy handed out, so the bytes that arrive + // can be checked against it. + if sum := sha256.Sum256(res.Content); res.ContentSHA256 != hex.EncodeToString(sum[:]) || res.ContentSHA256 == res.SHA256 { + t.Fatalf("content_sha256 = %s, want the hash of the redacted copy (%x), apart from sha256 %s", res.ContentSHA256, sum, res.SHA256) + } got := string(res.Content) if strings.Contains(got, "hunter2") { t.Fatalf("read returned the RCON password (spec §286):\n%s", got) @@ -633,6 +638,34 @@ func TestWriteDetectsConcurrentChange(t *testing.T) { } } +// TestWriteChecksTheContentDigest: a write lands only bytes that hash to the +// SHA-256 felis-api sent with them; bytes changed on the way touch nothing. +func TestWriteChecksTheContentDigest(t *testing.T) { + root, _ := worldRoot(t) + props := filepath.Join(root, "server.properties") + if err := os.WriteFile(props, []byte("motd=hello\n"), 0o644); err != nil { + t.Fatal(err) + } + sent := sha256.Sum256([]byte("motd=mine\n")) + req := Request{Op: OpWrite, Path: "server.properties", Content: []byte("motd=mint\n"), ContentSHA256: hex.EncodeToString(sent[:])} + res, err := Execute(root, req) + if err != nil || res.Code != CodeDigestMismatch { + t.Fatalf("changed write = %+v, %v; want %s", res, err, CodeDigestMismatch) + } + if b, _ := os.ReadFile(props); string(b) != "motd=hello\n" { + t.Fatalf("a changed write replaced the file with %q", b) + } + assertNoTemporaries(t, root) + + req.Content = []byte("motd=mine\n") + if res, err := Execute(root, req); err != nil || res.Code != "" { + t.Fatalf("write as sent = %+v, %v", res, err) + } + if b, _ := os.ReadFile(props); string(b) != "motd=mine\n" { + t.Fatalf("on disk %q, want the bytes as sent", b) + } +} + func assertNoTemporaries(t *testing.T, dir string) { t.Helper() des, err := os.ReadDir(dir) diff --git a/internal/fileedit/jobspec.go b/internal/fileedit/jobspec.go index 3088163..ed1794e 100644 --- a/internal/fileedit/jobspec.go +++ b/internal/fileedit/jobspec.go @@ -195,6 +195,9 @@ func FilesJob(p JobParams) (*batchv1.Job, error) { // secret, so it rides argv; only the content itself needs the env channel. switch p.Op { case OpWrite: + // The content's own SHA-256 goes beside it, so the Job writes only the + // bytes felis-api handed over (Request.ContentSHA256). + args = append(args, "--sha256", digest(p.Content)) if p.Expect != "" { args = append(args, "--expect-sha256", p.Expect) } diff --git a/internal/fileedit/jobspec_test.go b/internal/fileedit/jobspec_test.go index 5f97cff..e8abfb8 100644 --- a/internal/fileedit/jobspec_test.go +++ b/internal/fileedit/jobspec_test.go @@ -2,7 +2,9 @@ package fileedit import ( "bytes" + "crypto/sha256" "encoding/base64" + "encoding/hex" "slices" "strconv" "strings" @@ -274,6 +276,12 @@ func TestFilesJobContentEnv(t *testing.T) { if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") { t.Fatal("content must not appear in the container arguments") } + // Its SHA-256 does, so the Job writes only the bytes felis-api handed over. + sum := sha256.Sum256(p.Content) + args := job.Spec.Template.Spec.Containers[0].Args + if i := slices.Index(args, "--sha256"); i < 0 || i+1 >= len(args) || args[i+1] != hex.EncodeToString(sum[:]) { + t.Fatalf("args %q, want --sha256 %x", args, sum) + } }) // execve refuses one environment string over 128 KiB and the container never @@ -353,7 +361,8 @@ func TestFilesJobOpArgs(t *testing.T) { create := testParams(OpWrite) create.CreateOnly = true - if got := args(create); !slices.Equal(got, []string{"--create-only"}) { + // The content (none here) goes with its SHA-256. + if got := args(create); !slices.Equal(got, []string{"--sha256", "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855", "--create-only"}) { t.Errorf("create-only write args = %v", got) } readCreate := testParams(OpRead) diff --git a/panel/src/i18n/resources/en-US/errors.json b/panel/src/i18n/resources/en-US/errors.json index 7ca7f5a..d9772f6 100644 --- a/panel/src/i18n/resources/en-US/errors.json +++ b/panel/src/i18n/resources/en-US/errors.json @@ -72,9 +72,10 @@ "upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.", "length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.", "digest_mismatch": "The file was changed on its way to the server, so it was refused and nothing was written. Try again.", - "digest_required": "The upload came without a checksum, so it was refused. Reload the panel and upload it again.", - "bad_digest": "The upload's checksum was malformed, so it was refused. Reload the panel and upload it again.", + "digest_required": "The request came without a checksum, so it was refused. Reload the panel and try again.", + "bad_digest": "The request's checksum was malformed, so it was refused. Reload the panel and try again.", "file_unreadable": "The file could not be read: it was changed, moved or deleted after it was picked. Pick it again and upload.", + "read_damaged": "The file arrived damaged, so it was not opened: saving it would write the damage back. Open it again.", "upload_not_found": "This upload is gone: it was cancelled, already landed, sat idle for 6 hours, or the panel service restarted. Upload the file again.", "too_many_uploads": "You already have 4 large uploads in progress. Wait for one to finish, or cancel one, and try again.", "op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.", diff --git a/panel/src/i18n/resources/zh-CN/errors.json b/panel/src/i18n/resources/zh-CN/errors.json index 77fb34a..9cf48ce 100644 --- a/panel/src/i18n/resources/zh-CN/errors.json +++ b/panel/src/i18n/resources/zh-CN/errors.json @@ -72,9 +72,10 @@ "upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。", "length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。", "digest_mismatch": "文件在传输途中被改动了,服务器已拒收,什么都没有写入。请重试。", - "digest_required": "这次上传没有附带校验值,被拒绝了。请刷新面板后重新上传。", - "bad_digest": "这次上传附带的校验值格式不对,被拒绝了。请刷新面板后重新上传。", + "digest_required": "这次请求没有附带校验值,被拒绝了。请刷新面板后再试。", + "bad_digest": "这次请求附带的校验值格式不对,被拒绝了。请刷新面板后再试。", "file_unreadable": "读不出这个文件:它在选中之后被改动、移走或删除了。请重新选择文件再上传。", + "read_damaged": "文件传过来时损坏了,所以没有打开:保存它会把损坏写回去。请重新打开。", "upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。", "too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。", "op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。", diff --git a/panel/src/lib/api.test.ts b/panel/src/lib/api.test.ts index b5fe9d4..351af1e 100644 --- a/panel/src/lib/api.test.ts +++ b/panel/src/lib/api.test.ts @@ -1,4 +1,7 @@ import { createHash } from "node:crypto"; + +// hexOf is the SHA-256 (hex) of the bytes a file editor call carries. +const hexOf = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex"); import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; // Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary @@ -853,15 +856,29 @@ describe("image whitelist and builds wire shapes", () => { }); it("readServerFile GETs /servers/{name}/file and passes base64 through", async () => { - const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC" }); + const content_sha256 = hexOf(new Uint8Array([0, 1, 2])); + const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC", sha256: "a".repeat(64), content_sha256 }); vi.stubGlobal("fetch", fetchSpy); const res = await api.readServerFile("survival", "world/level.dat"); - expect(res.content).toBe("AAEC"); + expect(res).toEqual({ path: "world/level.dat", content: "AAEC", sha256: "a".repeat(64), content_sha256 }); const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat"); expect((opts as RequestInit).method).toBe("GET"); }); + // An editor that opened a damaged read would save the damage back. + it.each([ + ["hash otherwise", "AAED"], + ["are not base64 at all", "AA=E"], + ])("readServerFile refuses content whose bytes %s", async (_, content) => { + vi.stubGlobal( + "fetch", + fakeFetch({ path: "world/level.dat", content, sha256: "a".repeat(64), content_sha256: hexOf(new Uint8Array([0, 1, 2])) }), + ); + await expect(api.readServerFile("survival", "world/level.dat")).rejects.toMatchObject({ code: "read_damaged" }); + expect(humanizeError({ code: "read_damaged" })).toMatch(/arrived damaged/); + }); + it("writeServerFile PUTs {content} — an explicit \"\" is a deliberate truncate, not an omitted field", async () => { const fetchSpy = fakeFetch({ path: "a.txt", status: "written" }); vi.stubGlobal("fetch", fetchSpy); @@ -870,7 +887,7 @@ describe("image whitelist and builds wire shapes", () => { const [url, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; expect(String(url)).toBe("/servers/survival/file?path=a.txt"); expect((opts as RequestInit).method).toBe("PUT"); - expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" })); + expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "", content_sha256: hexOf("") })); }); it("writeServerFile sends the hash the read returned as expect_sha256", async () => { @@ -880,7 +897,7 @@ describe("image whitelist and builds wire shapes", () => { expect(res.sha256).toBe("b".repeat(64)); const [, opts] = (fetchSpy as unknown as ReturnType).mock.calls[0]; expect((opts as RequestInit).body).toBe( - JSON.stringify({ content: "aGk=", expect_sha256: "a".repeat(64) }), + JSON.stringify({ content: "aGk=", content_sha256: hexOf("hi"), expect_sha256: "a".repeat(64) }), ); }); }); @@ -1372,7 +1389,7 @@ describe("server file manager wire shapes", () => { const [url, opts] = sent(fetchSpy); expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml"); expect(opts.method).toBe("PUT"); - expect(opts.body).toBe(JSON.stringify({ content: "", create_only: true })); + expect(opts.body).toBe(JSON.stringify({ content: "", content_sha256: hexOf(""), create_only: true })); }); it("deleteServerFile DELETEs /servers/{name}/file with no body", async () => { diff --git a/panel/src/lib/api.ts b/panel/src/lib/api.ts index d70967a..0d0bb5b 100644 --- a/panel/src/lib/api.ts +++ b/panel/src/lib/api.ts @@ -45,7 +45,7 @@ import type { UpdateReport, } from "./types"; import { loadConfig } from "./config"; -import { sha256Of } from "./digest"; +import { base64Sha256, sha256Of } from "./digest"; import i18next from "i18next"; // Typed client for the felis-api external face (spec §7). Credentials are sent so @@ -768,31 +768,49 @@ export const api = rejectingSync({ // readServerFile returns one file's bytes (base64) and the sha256 of the file // as stored. A file over the read ceiling is a 413, never a silent truncation, // because a later save of a truncated body would destroy the rest of the file. - readServerFile: (name: string, path: string) => - request<{ path: string; content: string; sha256: string }>( + // The content must hash to content_sha256, the digest of the bytes as sent: a + // read damaged on the way is refused (read_damaged) rather than opened, since + // saving it would write the damage back. + readServerFile: async (name: string, path: string) => { + const r = await request<{ path: string; content: string; sha256: string; content_sha256: string }>( "GET", urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, - ), + ); + let got = ""; + try { + got = await base64Sha256(r.content); + } catch { + /* not base64 at all: damaged too */ + } + if (got !== r.content_sha256) throw clientError("read_damaged"); + return r; + }, // writeServerFile atomically replaces a file's contents (creating it if // absent). Sending an explicit "" is a deliberate truncate; the wire field is // required, but that is enforced by the caller (this method always sends one). // With expectSha256 (the hash the read returned) a file someone changed since // is refused with 409 file_changed; without it the write is unconditional. - writeServerFile: (name: string, path: string, content: string, expectSha256?: string) => + // The content goes with its SHA-256 (content_sha256), so content changed on + // the way is refused (digest_mismatch) and nothing is written. + writeServerFile: async (name: string, path: string, content: string, expectSha256?: string) => request<{ path: string; status: string; sha256: string }>( "PUT", urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, - expectSha256 ? { content, expect_sha256: expectSha256 } : { content }, + { + content, + content_sha256: await base64Sha256(content), + ...(expectSha256 ? { expect_sha256: expectSha256 } : {}), + }, ), // createServerFile makes a new file with content, and only if nothing is at the // path yet: something that appeared meanwhile is 409 file_exists, never replaced. - createServerFile: (name: string, path: string, content: string) => + createServerFile: async (name: string, path: string, content: string) => request<{ path: string; status: string; sha256: string }>( "PUT", urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, - { content, create_only: true }, + { content, content_sha256: await base64Sha256(content), create_only: true }, ), // deleteServerFile deletes a file, a link (never what it names) or a folder with @@ -1388,6 +1406,9 @@ export function humanizeError(e: unknown): string { return t("bad_digest"); case "file_unreadable": return t("file_unreadable"); + // A file opened in the editor whose bytes arrived damaged (readServerFile). + case "read_damaged": + return t("read_damaged"); // An upload sent in parts: the session is gone (cancelled, landed, idle for // 6 hours, or felis-api restarted), or the account holds four already. case "upload_not_found": diff --git a/panel/src/lib/digest.ts b/panel/src/lib/digest.ts index 30e8de1..571694a 100644 --- a/panel/src/lib/digest.ts +++ b/panel/src/lib/digest.ts @@ -22,3 +22,13 @@ export async function sha256Of(blob: Blob): Promise<{ hex: string; header: strin } return { hex, header: `sha-256=:${btoa(bin)}:` }; } + +// base64Sha256 answers the hex SHA-256 of the bytes b64 encodes: the file +// editor's content_sha256, which a save sends with its content and a read is +// checked against. A string that is not base64 throws. +export async function base64Sha256(b64: string): Promise { + const bin = atob(b64); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return (await sha256Of(new Blob([bytes]))).hex; +} diff --git a/panel/src/lib/openapi.gen.ts b/panel/src/lib/openapi.gen.ts index 7b5c8ef..345004d 100644 --- a/panel/src/lib/openapi.gen.ts +++ b/panel/src/lib/openapi.gen.ts @@ -1412,7 +1412,7 @@ export interface paths { get: operations["readServerFile"]; /** * Write a file in a server's world volume (owner-or-admin; server must be stopped). - * @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. Audited as file.write. + * @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Audited as file.write. */ put: operations["writeServerFile"]; post?: never; @@ -6921,6 +6921,8 @@ export interface operations { content: string; /** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */ sha256: string; + /** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */ + content_sha256: string; }; }; }; @@ -6962,6 +6964,15 @@ export interface operations { "application/json": components["schemas"]["Error"]; }; }; + /** @description The file's bytes do not hash to the digest the file Job sent with them (read_damaged): they changed on the way to felis-api. Read it again. */ + 502: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["Error"]; + }; + }; 503: components["responses"]["ServiceUnavailable"]; /** @description The file Job did not finish in time; retry. */ 504: { @@ -6995,6 +7006,8 @@ export interface operations { * @description Base64-encoded file bytes. */ content: string; + /** @description The SHA-256 (lowercase hex) of the decoded content. Absent is 400 digest_required, malformed 400 bad_digest, and content that does not hash to it 400 digest_mismatch; nothing is written. */ + content_sha256: string; /** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */ expect_sha256?: string; /** @description true writes only if nothing is at the path yet (409 file_exists otherwise), for making a new file without replacing one that appeared meanwhile. Cannot be combined with expect_sha256. */ @@ -7018,7 +7031,7 @@ export interface operations { }; }; }; - /** @description Missing path, malformed body, invalid server name, or a path that escapes the world root. */ + /** @description Missing path, malformed body, invalid server name, or a path that escapes the world root (bad_request, bad_path), or content that came without its SHA-256 (digest_required), with a malformed one (bad_digest), or changed on the way (digest_mismatch). */ 400: { headers: { [name: string]: unknown;