fix(files): 编辑器保存和打开也逐跳核对 SHA-256,途中变了的内容不写盘也不打开
This commit is contained in:
18 files changed
+369
-57
No files matched your search
+8
-2
@@ -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
@@ -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
@@ -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' }
|
||||||
|
|||||||
@@ -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,10 +79,16 @@ 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"`
|
||||||
ExpectSHA256 string `json:"expect_sha256,omitempty"`
|
ContentSHA256 string `json:"content_sha256"`
|
||||||
CreateOnly bool `json:"create_only,omitempty"`
|
ExpectSHA256 string `json:"expect_sha256,omitempty"`
|
||||||
|
CreateOnly bool `json:"create_only,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// handleListFiles serves GET /api/v1/servers/{name}/files?path=… — one directory's
|
// 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 {
|
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"))
|
||||||
|
|||||||
@@ -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"}`},
|
||||||
@@ -436,14 +436,17 @@ func TestFileEditorHandlers(t *testing.T) {
|
|||||||
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
|
||||||
}
|
}
|
||||||
var resp struct {
|
var resp struct {
|
||||||
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"},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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 []byte
|
// Content; bytes that hash otherwise are not written (CodeDigestMismatch).
|
||||||
Expect string
|
Content []byte
|
||||||
|
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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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.",
|
||||||
|
|||||||
@@ -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": "看不到这次操作的进度了。刷新列表看看文件有没有写入。",
|
||||||
|
|||||||
@@ -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
@@ -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":
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
Reference in new issue
Block a user