fix(files): 编辑器保存和打开也逐跳核对 SHA-256,途中变了的内容不写盘也不打开

This commit is contained in:
Lemon-miaow committed 2026-09-29 01:43:37 +08:00
1 parent cb3065da1d
commit 2b9cd1869c
18 files changed
+361 -49

No files matched your search

+8 -2
View File
@@ -47,7 +47,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
to := fs.String("to", "", "rename only: the destination path") to := fs.String("to", "", "rename only: the destination path")
sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from") 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") 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") overwrite := fs.Bool("overwrite", false, "upload and unzip: replace files already there")
if err := fs.Parse(args); err != nil { if err := fs.Parse(args); err != nil {
return 2 return 2
@@ -70,12 +70,18 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
// channel that must be a valid string. // channel that must be a valid string.
switch *op { switch *op {
case fileedit.OpWrite: 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) content, err := fileedit.ContentFromEnv(os.LookupEnv)
if err != nil { if err != nil {
fmt.Fprintf(stderr, "felis files: %v\n", err) fmt.Fprintf(stderr, "felis files: %v\n", err)
return 2 return 2
} }
req.Content = content req.Content, req.ContentSHA256 = content, *sum
case fileedit.OpUpload: case fileedit.OpUpload:
token := os.Getenv(fileedit.UploadTokenEnv) token := os.Getenv(fileedit.UploadTokenEnv)
if *sourceURL == "" || token == "" { if *sourceURL == "" || token == "" {
+33 -3
View File
@@ -238,10 +238,11 @@ func TestCmdFilesUpload(t *testing.T) {
func TestCmdFilesWrite(t *testing.T) { func TestCmdFilesWrite(t *testing.T) {
root := t.TempDir() 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) { t.Run("reassembles the content parts", func(t *testing.T) {
content := []byte("[]\r\n")
t.Setenv(fileedit.ContentPartsEnv, "1") t.Setenv(fileedit.ContentPartsEnv, "1")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content)) t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
var stdout, stderr bytes.Buffer var stdout, stderr bytes.Buffer
@@ -261,13 +262,42 @@ func TestCmdFilesWrite(t *testing.T) {
t.Setenv(fileedit.ContentPartsEnv, "2") t.Setenv(fileedit.ContentPartsEnv, "2")
t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x"))) t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x")))
var stdout, stderr bytes.Buffer 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) t.Fatalf("exit %d, want 2", code)
} }
if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) { if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
t.Fatalf("an incomplete spec wrote a file: %v", 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 // A caller-fault outcome is a successful run carrying a code, so felis-api can
+28 -4
View File
@@ -4615,7 +4615,7 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [path, content, sha256] required: [path, content, sha256, content_sha256]
properties: properties:
path: { type: string } path: { type: string }
content: { type: string, format: byte, description: Base64-encoded file bytes. } content: { type: string, format: byte, description: Base64-encoded file bytes. }
@@ -4625,6 +4625,13 @@ paths:
description: >- description: >-
SHA-256 of the file as stored (before the rcon.password redaction in 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. 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': '400':
description: Missing path, invalid server name, or a path that escapes the world root. description: Missing path, invalid server name, or a path that escapes the world root.
content: content:
@@ -4649,6 +4656,13 @@ paths:
content: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } 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': '503':
$ref: '#/components/responses/ServiceUnavailable' $ref: '#/components/responses/ServiceUnavailable'
'504': '504':
@@ -4671,7 +4685,10 @@ paths:
root is refused. The replacement is atomic (a synced temporary sibling renamed 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. 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; 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-face: [external]
x-felis-tier: app x-felis-tier: app
security: [{ sessionCookie: [] }] security: [{ sessionCookie: [] }]
@@ -4688,9 +4705,16 @@ paths:
application/json: application/json:
schema: schema:
type: object type: object
required: [content] required: [content, content_sha256]
properties: properties:
content: { type: string, format: byte, description: Base64-encoded file bytes. } 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: expect_sha256:
type: string type: string
pattern: '^[0-9a-f]{64}$' pattern: '^[0-9a-f]{64}$'
@@ -4717,7 +4741,7 @@ paths:
status: { type: string, const: written } status: { type: string, const: written }
sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes written. } sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes written. }
'400': '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: content:
application/json: application/json:
schema: { $ref: '#/components/schemas/Error' } schema: { $ref: '#/components/schemas/Error' }
+33 -1
View File
@@ -4,6 +4,7 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"errors" "errors"
"io" "io"
"net/http" "net/http"
@@ -78,8 +79,14 @@ type FileEditor interface {
// nothing is at the path yet (409 file_exists otherwise), so it can never // 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 — // 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. // 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 { type writeFileRequest struct {
Content *[]byte `json:"content"` Content *[]byte `json:"content"`
ContentSHA256 string `json:"content_sha256"`
ExpectSHA256 string `json:"expect_sha256,omitempty"` ExpectSHA256 string `json:"expect_sha256,omitempty"`
CreateOnly bool `json:"create_only,omitempty"` CreateOnly bool `json:"create_only,omitempty"`
} }
@@ -145,7 +152,12 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
if content == nil { if content == nil {
content = []byte{} // an empty file is "", never null 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 // 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")) "create_only and expect_sha256 cannot be combined"))
return 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); // 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 // 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())) writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
case errors.Is(err, fileedit.ErrExists): case errors.Is(err, fileedit.ErrExists):
writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error())) 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): case errors.Is(err, context.DeadlineExceeded):
writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout", writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
"the file operation did not finish in time; retry shortly")) "the file operation did not finish in time; retry shortly"))
+68 -11
View File
@@ -180,7 +180,7 @@ func TestFileEditorStoppedGate(t *testing.T) {
}{ }{
{"list", "GET", "/api/v1/servers/survival/files?path=config", ""}, {"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"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", ""}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, {"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", ""}, {"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"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", ""}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, {"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", ""}, {"list", "GET", "/api/v1/servers/survival/files", ""},
{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""}, {"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", ""}, {"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""}, {"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`}, {"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
@@ -439,11 +439,14 @@ func TestFileEditorHandlers(t *testing.T) {
Path string `json:"path"` Path string `json:"path"`
Content []byte `json:"content"` Content []byte `json:"content"`
SHA256 string `json:"sha256"` SHA256 string `json:"sha256"`
Content256 string `json:"content_sha256"`
} }
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("body not JSON: %v", err) 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) t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
} }
}) })
@@ -465,7 +468,7 @@ func TestFileEditorHandlers(t *testing.T) {
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", 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 { if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) 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) files.sum = strings.Repeat("b", 64)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", 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 { if w.Code != http.StatusOK {
t.Fatalf("code = %d (%s)", w.Code, w.Body.String()) 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) files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", 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" { 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()) 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, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties", 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 { if w.Code != http.StatusOK {
t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String()) 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) { t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
api, _, _, files := mkFiles(t) api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} 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 { if err != nil {
t.Fatalf("marshal: %v", err) t.Fatalf("marshal: %v", err)
} }
@@ -738,12 +742,12 @@ func TestFileManagerHandlers(t *testing.T) {
api, _, _, files := mkFiles(t) api, _, _, files := mkFiles(t)
api.External = staticExternal{p: owner} api.External = staticExternal{p: owner}
w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml", 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 != "" { 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()) 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", 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 { if w.Code != http.StatusOK || files.gotCreateOnly {
t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, 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[:]) + ":" 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: "[email protected]", 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 // 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 // way a client that streams or lies would send it. Its Content-Digest is that
// of the body. // 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"}, {"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"}, {"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
{"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"}, {"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"}, {"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
} }
+1 -1
View File
@@ -107,7 +107,7 @@ func maintenanceOps(t *testing.T) (*API, *fakeCluster, []maintenanceOp) {
{"backup", maintenance.KindBackup, "POST", "/api/v1/servers/survival/backup", "", {"backup", maintenance.KindBackup, "POST", "/api/v1/servers/survival/backup", "",
func() int { return backuper.calls }}, func() int { return backuper.calls }},
{"file write", maintenance.KindFileWrite, "PUT", "/api/v1/servers/survival/file?path=server.properties", {"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", {"file mkdir", maintenance.KindFileWrite, "POST", "/api/v1/servers/survival/files/mkdir?path=plugins",
"", func() int { return files.calls }}, "", func() int { return files.calls }},
{"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar", {"file delete", maintenance.KindFileWrite, "DELETE", "/api/v1/servers/survival/file?path=old.jar",
+11
View File
@@ -77,6 +77,9 @@ var (
// ErrExists is a create, mkdir, rename or upload whose target is already // ErrExists is a create, mkdir, rename or upload whose target is already
// there. // there.
ErrExists = errors.New("fileedit: the target already exists") 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 // 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 // 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. // 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) { func (e *Editor) Read(ctx context.Context, server, path string) ([]byte, string, error) {
res, err := e.run(ctx, server, JobParams{Op: OpRead, Path: path}) res, err := e.run(ctx, server, JobParams{Op: OpRead, Path: path})
if err != nil { if err != nil {
return nil, "", err 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, // 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. // not an error — normalise so the caller never has to distinguish nil from empty.
if res.Content == nil { if res.Content == nil {
@@ -457,6 +466,8 @@ func resultError(res Result) error {
return fmt.Errorf("%w: %s", ErrNoSpace, res.Error) return fmt.Errorf("%w: %s", ErrNoSpace, res.Error)
case CodeExists: case CodeExists:
return fmt.Errorf("%w: %s", ErrExists, res.Error) return fmt.Errorf("%w: %s", ErrExists, res.Error)
case CodeDigestMismatch:
return fmt.Errorf("%w: %s", ErrDigestMismatch, res.Error)
default: default:
return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error) return fmt.Errorf("fileedit: file operation failed (%s): %s", res.Code, res.Error)
} }
+27 -3
View File
@@ -2,6 +2,7 @@ package fileedit
import ( import (
"context" "context"
"encoding/hex"
"encoding/json" "encoding/json"
"errors" "errors"
"testing" "testing"
@@ -79,7 +80,7 @@ func TestEditorRendersParams(t *testing.T) {
}) })
t.Run("read", func(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"}} e := &Editor{Runner: r, Config: Config{Image: "img"}}
got, sum, err := e.Read(context.Background(), "survival", "server.properties") 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 // every time. If it ever cached one, two operations would collide on a name
// felis-api has no permission to delete. // felis-api has no permission to delete.
func TestEditorMintsAFreshOpID(t *testing.T) { 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"}} e := &Editor{Runner: r, Config: Config{Image: "img"}}
for range 3 { for range 3 {
@@ -198,6 +199,7 @@ func TestEditorMapsResultCodes(t *testing.T) {
{"changed since read", CodeConflict, ErrConflict}, {"changed since read", CodeConflict, ErrConflict},
{"volume full", CodeNoSpace, ErrNoSpace}, {"volume full", CodeNoSpace, ErrNoSpace},
{"already there", CodeExists, ErrExists}, {"already there", CodeExists, ErrExists},
{"changed on the way", CodeDigestMismatch, ErrDigestMismatch},
} }
for _, tc := range cases { for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) { 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 // TestEditorNormalisesEmptyResults pins that "nothing there" is a success, not a
// nil surprise: an empty directory lists as [] and a zero-length file reads as // 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. // empty bytes, so no caller has to distinguish nil from empty.
func TestEditorNormalisesEmptyResults(t *testing.T) { 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"}} e := &Editor{Runner: r, Config: Config{Image: "img"}}
ls, err := e.List(context.Background(), "survival", "empty") ls, err := e.List(context.Background(), "survival", "empty")
+24 -4
View File
@@ -84,6 +84,10 @@ const (
// there. None of them replaces anything unless told to (an upload's // there. None of them replaces anything unless told to (an upload's
// Overwrite), so a name collision is reported rather than resolved. // Overwrite), so a name collision is reported rather than resolved.
CodeExists = "exists" 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 // 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 // conflict, the file as it is now. A client hands it back as the expected
// hash of its next write (see write). // hash of its next write (see write).
SHA256 string `json:"sha256,omitempty"` 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 // 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 // unzip would replace: the first of them, up to MaxConflicts and 8 KiB of
@@ -213,9 +221,11 @@ type Request struct {
To string To string
// Content and Expect are a write's bytes and precondition: when Expect is // 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 // non-empty, the write lands only if the file's current SHA-256 (hex) equals
// it. // it. ContentSHA256, when set, is the SHA-256 (hex) felis-api computed over
// Content; bytes that hash otherwise are not written (CodeDigestMismatch).
Content []byte Content []byte
Expect string Expect string
ContentSHA256 string
// CreateOnly makes a write refuse a path that already exists. It is the // 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 // panel's "new file", which must never truncate a file it did not know was
// there. // there.
@@ -295,7 +305,7 @@ func Execute(root string, req Request) (Result, error) {
case OpRead: case OpRead:
return read(r, path), nil return read(r, path), nil
case OpWrite: 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: case OpMkdir:
return mkdir(r, path), nil return mkdir(r, path), nil
case OpDelete: case OpDelete:
@@ -432,7 +442,7 @@ func read(r *os.Root, name string) Result {
if redact { if redact {
content = RedactProps(b) 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 // 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. // being overwritten, which is how two people editing the same file find out.
// The world lock (internal/maintenance) already serialises writes, so the check // The world lock (internal/maintenance) already serialises writes, so the check
// and the rename cannot interleave with another write. // 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 { if len(content) > MaxWriteBytes {
// Defence in depth: felis-api already refuses an oversized write with a 413 // 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 // before rendering the Job. Re-checking here keeps the ceiling true even if
+33
View File
@@ -435,6 +435,11 @@ func TestReadRedactsRconPassword(t *testing.T) {
if sum := sha256.Sum256([]byte(props)); res.SHA256 != hex.EncodeToString(sum[:]) { 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) 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) got := string(res.Content)
if strings.Contains(got, "hunter2") { if strings.Contains(got, "hunter2") {
t.Fatalf("read returned the RCON password (spec §286):\n%s", got) 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) { func assertNoTemporaries(t *testing.T, dir string) {
t.Helper() t.Helper()
des, err := os.ReadDir(dir) des, err := os.ReadDir(dir)
+3
View File
@@ -195,6 +195,9 @@ func FilesJob(p JobParams) (*batchv1.Job, error) {
// secret, so it rides argv; only the content itself needs the env channel. // secret, so it rides argv; only the content itself needs the env channel.
switch p.Op { switch p.Op {
case OpWrite: 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 != "" { if p.Expect != "" {
args = append(args, "--expect-sha256", p.Expect) args = append(args, "--expect-sha256", p.Expect)
} }
+10 -1
View File
@@ -2,7 +2,9 @@ package fileedit
import ( import (
"bytes" "bytes"
"crypto/sha256"
"encoding/base64" "encoding/base64"
"encoding/hex"
"slices" "slices"
"strconv" "strconv"
"strings" "strings"
@@ -274,6 +276,12 @@ func TestFilesJobContentEnv(t *testing.T) {
if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") { if strings.Contains(strings.Join(job.Spec.Template.Spec.Containers[0].Args, " "), "motd=hello") {
t.Fatal("content must not appear in the container arguments") 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 // 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 := testParams(OpWrite)
create.CreateOnly = true 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) t.Errorf("create-only write args = %v", got)
} }
readCreate := testParams(OpRead) readCreate := testParams(OpRead)
+3 -2
View File
@@ -72,9 +72,10 @@
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.", "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.", "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_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.", "digest_required": "The request came without a checksum, so it was refused. Reload the panel and try again.",
"bad_digest": "The upload's checksum was malformed, so it was refused. Reload the panel and upload it 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.", "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.", "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.", "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.", "op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.",
+3 -2
View File
@@ -72,9 +72,10 @@
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。", "upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。", "length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
"digest_mismatch": "文件在传输途中被改动了,服务器已拒收,什么都没有写入。请重试。", "digest_mismatch": "文件在传输途中被改动了,服务器已拒收,什么都没有写入。请重试。",
"digest_required": "这次上传没有附带校验值,被拒绝了。请刷新面板后重新上传。", "digest_required": "这次请求没有附带校验值,被拒绝了。请刷新面板后再试。",
"bad_digest": "这次上传附带的校验值格式不对,被拒绝了。请刷新面板后重新上传。", "bad_digest": "这次请求附带的校验值格式不对,被拒绝了。请刷新面板后再试。",
"file_unreadable": "读不出这个文件:它在选中之后被改动、移走或删除了。请重新选择文件再上传。", "file_unreadable": "读不出这个文件:它在选中之后被改动、移走或删除了。请重新选择文件再上传。",
"read_damaged": "文件传过来时损坏了,所以没有打开:保存它会把损坏写回去。请重新打开。",
"upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。", "upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。",
"too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。", "too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。",
"op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。", "op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。",
+22 -5
View File
@@ -1,4 +1,7 @@
import { createHash } from "node:crypto"; 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"; import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary // 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 () => { 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); vi.stubGlobal("fetch", fetchSpy);
const res = await api.readServerFile("survival", "world/level.dat"); 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<typeof vi.fn>).mock.calls[0]; const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat"); expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat");
expect((opts as RequestInit).method).toBe("GET"); 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 () => { it("writeServerFile PUTs {content} — an explicit \"\" is a deliberate truncate, not an omitted field", async () => {
const fetchSpy = fakeFetch({ path: "a.txt", status: "written" }); const fetchSpy = fakeFetch({ path: "a.txt", status: "written" });
vi.stubGlobal("fetch", fetchSpy); vi.stubGlobal("fetch", fetchSpy);
@@ -870,7 +887,7 @@ describe("image whitelist and builds wire shapes", () => {
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect(String(url)).toBe("/servers/survival/file?path=a.txt"); expect(String(url)).toBe("/servers/survival/file?path=a.txt");
expect((opts as RequestInit).method).toBe("PUT"); 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 () => { 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)); expect(res.sha256).toBe("b".repeat(64));
const [, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0]; const [, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
expect((opts as RequestInit).body).toBe( 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); const [url, opts] = sent(fetchSpy);
expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml"); expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml");
expect(opts.method).toBe("PUT"); 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 () => { it("deleteServerFile DELETEs /servers/{name}/file with no body", async () => {
+29 -8
View File
@@ -45,7 +45,7 @@ import type {
UpdateReport, UpdateReport,
} from "./types"; } from "./types";
import { loadConfig } from "./config"; import { loadConfig } from "./config";
import { sha256Of } from "./digest"; import { base64Sha256, sha256Of } from "./digest";
import i18next from "i18next"; import i18next from "i18next";
// Typed client for the felis-api external face (spec §7). Credentials are sent so // 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 // 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, // 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. // because a later save of a truncated body would destroy the rest of the file.
readServerFile: (name: string, path: string) => // The content must hash to content_sha256, the digest of the bytes as sent: a
request<{ path: string; content: string; sha256: string }>( // 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", "GET",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, 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 // writeServerFile atomically replaces a file's contents (creating it if
// absent). Sending an explicit "" is a deliberate truncate; the wire field is // 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). // 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 // With expectSha256 (the hash the read returned) a file someone changed since
// is refused with 409 file_changed; without it the write is unconditional. // 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 }>( request<{ path: string; status: string; sha256: string }>(
"PUT", "PUT",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, 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 // 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. // 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 }>( request<{ path: string; status: string; sha256: string }>(
"PUT", "PUT",
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`, 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 // 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"); return t("bad_digest");
case "file_unreadable": case "file_unreadable":
return t("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 // 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. // 6 hours, or felis-api restarted), or the account holds four already.
case "upload_not_found": case "upload_not_found":
+10
View File
@@ -22,3 +22,13 @@ export async function sha256Of(blob: Blob): Promise<{ hex: string; header: strin
} }
return { hex, header: `sha-256=:${btoa(bin)}:` }; 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<string> {
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;
}
+15 -2
View File
@@ -1412,7 +1412,7 @@ export interface paths {
get: operations["readServerFile"]; get: operations["readServerFile"];
/** /**
* Write a file in a server's world volume (owner-or-admin; server must be stopped). * 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"]; put: operations["writeServerFile"];
post?: never; post?: never;
@@ -6921,6 +6921,8 @@ export interface operations {
content: string; 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. */ /** @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; 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"]; "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"]; 503: components["responses"]["ServiceUnavailable"];
/** @description The file Job did not finish in time; retry. */ /** @description The file Job did not finish in time; retry. */
504: { 504: {
@@ -6995,6 +7006,8 @@ export interface operations {
* @description Base64-encoded file bytes. * @description Base64-encoded file bytes.
*/ */
content: string; 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. */ /** @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; 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. */ /** @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: { 400: {
headers: { headers: {
[name: string]: unknown; [name: string]: unknown;