Unverified Commit 2b9cd186 authored by Lemon-miaow's avatar Lemon-miaow
Browse files

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

parent cb3065da
Loading
Loading
Loading
Loading
+8 −2
Changes for cmd/felis/files.go: 8 added lines, 2 removed lines.
Original line number Diff line number Diff line
@@ -47,7 +47,7 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
	to := fs.String("to", "", "rename only: the destination path")
	sourceURL := fs.String("source-url", "", "upload only: felis-api URL to fetch the bytes from")
	size := fs.Int64("size", -1, "upload only: the byte count the fetched file must have")
	sum := fs.String("sha256", "", "upload only: the SHA-256 (hex) the fetched file must have")
	sum := fs.String("sha256", "", "write and upload: the SHA-256 (hex) the content or the fetched file must have")
	overwrite := fs.Bool("overwrite", false, "upload and unzip: replace files already there")
	if err := fs.Parse(args); err != nil {
		return 2
@@ -70,12 +70,18 @@ func cmdFiles(args []string, stdout, stderr io.Writer) int {
	// channel that must be a valid string.
	switch *op {
	case fileedit.OpWrite:
		// The content's SHA-256 comes with it, so bytes that changed on the way
		// to this Job are refused rather than written (Request.ContentSHA256).
		if *sum == "" {
			fmt.Fprintln(stderr, "felis files: a write needs --sha256")
			return 2
		}
		content, err := fileedit.ContentFromEnv(os.LookupEnv)
		if err != nil {
			fmt.Fprintf(stderr, "felis files: %v\n", err)
			return 2
		}
		req.Content = content
		req.Content, req.ContentSHA256 = content, *sum
	case fileedit.OpUpload:
		token := os.Getenv(fileedit.UploadTokenEnv)
		if *sourceURL == "" || token == "" {
+33 −3
Changes for cmd/felis/files_test.go: 33 added lines, 3 removed lines.
Original line number Diff line number Diff line
@@ -238,10 +238,11 @@ func TestCmdFilesUpload(t *testing.T) {

func TestCmdFilesWrite(t *testing.T) {
	root := t.TempDir()
	args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root}
	content := []byte("[]\r\n")
	sum := sha256.Sum256(content)
	args := []string{"--op", "write", "--path", "ops.json", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}

	t.Run("reassembles the content parts", func(t *testing.T) {
		content := []byte("[]\r\n")
		t.Setenv(fileedit.ContentPartsEnv, "1")
		t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
		var stdout, stderr bytes.Buffer
@@ -261,13 +262,42 @@ func TestCmdFilesWrite(t *testing.T) {
		t.Setenv(fileedit.ContentPartsEnv, "2")
		t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("x")))
		var stdout, stderr bytes.Buffer
		if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 {
		if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 2 {
			t.Fatalf("exit %d, want 2", code)
		}
		if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
			t.Fatalf("an incomplete spec wrote a file: %v", err)
		}
	})

	// Without the content's SHA-256 the Job could not tell bytes changed on the
	// way from the bytes felis-api sent.
	t.Run("a write without its SHA-256 exits 2 and writes nothing", func(t *testing.T) {
		t.Setenv(fileedit.ContentPartsEnv, "1")
		t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString(content))
		var stdout, stderr bytes.Buffer
		if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root}, &stdout, &stderr); code != 2 {
			t.Fatalf("exit %d, want 2", code)
		}
		if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
			t.Fatalf("a write without its SHA-256 wrote a file: %v", err)
		}
	})

	t.Run("content that changed on the way is a result and writes nothing", func(t *testing.T) {
		t.Setenv(fileedit.ContentPartsEnv, "1")
		t.Setenv(fileedit.ContentEnv+"_0", base64.StdEncoding.EncodeToString([]byte("[]\n")))
		var stdout, stderr bytes.Buffer
		if code := cmdFiles([]string{"--op", "write", "--path", "new.txt", "--worlds-root", root, "--sha256", hex.EncodeToString(sum[:])}, &stdout, &stderr); code != 0 {
			t.Fatalf("exit %d, stderr %q", code, stderr.String())
		}
		if res := filesResult(t, stdout.String()); res.Code != fileedit.CodeDigestMismatch {
			t.Fatalf("result = %+v, want %s", res, fileedit.CodeDigestMismatch)
		}
		if _, err := os.Lstat(filepath.Join(root, "new.txt")); !os.IsNotExist(err) {
			t.Fatalf("changed content wrote a file: %v", err)
		}
	})
}

// A caller-fault outcome is a successful run carrying a code, so felis-api can
+28 −4
Changes for docs/openapi.yaml: 28 added lines, 4 removed lines.
Original line number Diff line number Diff line
@@ -4615,7 +4615,7 @@ paths:
            application/json:
              schema:
                type: object
                required: [path, content, sha256]
                required: [path, content, sha256, content_sha256]
                properties:
                  path: { type: string }
                  content: { type: string, format: byte, description: Base64-encoded file bytes. }
@@ -4625,6 +4625,13 @@ paths:
                    description: >-
                      SHA-256 of the file as stored (before the rcon.password redaction in
                      server.properties). Send it back as expect_sha256 on the next write.
                  content_sha256:
                    type: string
                    pattern: '^[0-9a-f]{64}$'
                    description: >-
                      SHA-256 of the decoded content as sent (after any redaction). A
                      client that gets content hashing otherwise got it damaged on the
                      way, and reads it again.
        '400':
          description: Missing path, invalid server name, or a path that escapes the world root.
          content:
@@ -4649,6 +4656,13 @@ paths:
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '502':
          description: >-
            The file's bytes do not hash to the digest the file Job sent with them
            (read_damaged): they changed on the way to felis-api. Read it again.
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
        '503':
          $ref: '#/components/responses/ServiceUnavailable'
        '504':
@@ -4671,7 +4685,10 @@ paths:
        root is refused. The replacement is atomic (a synced temporary sibling renamed
        over the file, keeping its mode), so a failed write leaves the old file whole.
        With expect_sha256 the write lands only if the file still has that hash;
        otherwise 409 file_changed. Audited as file.write.
        otherwise 409 file_changed. content_sha256 is the SHA-256 of the content:
        content that hashes otherwise changed on the way and is refused (400
        digest_mismatch) before a Job starts, and the Job checks the bytes it received
        the same way before writing. Audited as file.write.
      x-felis-face: [external]
      x-felis-tier: app
      security: [{ sessionCookie: [] }]
@@ -4688,9 +4705,16 @@ paths:
          application/json:
            schema:
              type: object
              required: [content]
              required: [content, content_sha256]
              properties:
                content: { type: string, format: byte, description: Base64-encoded file bytes. }
                content_sha256:
                  type: string
                  pattern: '^[0-9a-f]{64}$'
                  description: >-
                    The SHA-256 (lowercase hex) of the decoded content. Absent is 400
                    digest_required, malformed 400 bad_digest, and content that does not
                    hash to it 400 digest_mismatch; nothing is written.
                expect_sha256:
                  type: string
                  pattern: '^[0-9a-f]{64}$'
@@ -4717,7 +4741,7 @@ paths:
                  status: { type: string, const: written }
                  sha256: { type: string, pattern: '^[0-9a-f]{64}$', description: SHA-256 of the bytes written. }
        '400':
          description: Missing path, malformed body, invalid server name, or a path that escapes the world root.
          description: Missing path, malformed body, invalid server name, or a path that escapes the world root (bad_request, bad_path), or content that came without its SHA-256 (digest_required), with a malformed one (bad_digest), or changed on the way (digest_mismatch).
          content:
            application/json:
              schema: { $ref: '#/components/schemas/Error' }
+33 −1
Changes for internal/api/handlers_files.go: 33 added lines, 1 removed line.
Original line number Diff line number Diff line
@@ -4,6 +4,7 @@ import (
	"context"
	"crypto/sha256"
	"encoding/base64"
	"encoding/hex"
	"errors"
	"io"
	"net/http"
@@ -78,8 +79,14 @@ type FileEditor interface {
// nothing is at the path yet (409 file_exists otherwise), so it can never
// truncate a file the caller did not know was there. The two cannot be combined —
// one says the file exists, the other that it must not.
//
// ContentSHA256 is required: the SHA-256 (hex) of the decoded content, which
// the caller computes over the bytes it means to write. Content that hashes
// otherwise changed on the way and is refused (400 digest_mismatch) before a Job
// starts; the Job checks the bytes it received the same way before it writes.
type writeFileRequest struct {
	Content       *[]byte `json:"content"`
	ContentSHA256 string  `json:"content_sha256"`
	ExpectSHA256  string  `json:"expect_sha256,omitempty"`
	CreateOnly    bool    `json:"create_only,omitempty"`
}
@@ -145,7 +152,12 @@ func (a *API) handleReadFile(w http.ResponseWriter, r *http.Request) {
	if content == nil {
		content = []byte{} // an empty file is "", never null
	}
	writeJSON(w, http.StatusOK, map[string]any{"path": path, "content": content, "sha256": sum})
	// content_sha256 is of the bytes as sent (sha256 is of the file on disk,
	// before any redaction), so the panel can tell a damaged read from the file.
	got := sha256.Sum256(content)
	writeJSON(w, http.StatusOK, map[string]any{
		"path": path, "content": content, "sha256": sum, "content_sha256": hex.EncodeToString(got[:]),
	})
}

// handleWriteFile serves PUT /api/v1/servers/{name}/file?path=… — replace a file's
@@ -201,6 +213,20 @@ func (a *API) handleWriteFile(w http.ResponseWriter, r *http.Request) {
			"create_only and expect_sha256 cannot be combined"))
		return
	}
	switch sum := sha256.Sum256(*body.Content); {
	case body.ContentSHA256 == "":
		writeError(w, r, newError(http.StatusBadRequest, "digest_required",
			"send the SHA-256 of the content as content_sha256"))
		return
	case !sha256Hex.MatchString(body.ContentSHA256):
		writeError(w, r, newError(http.StatusBadRequest, "bad_digest",
			"content_sha256 must be the 64-digit lowercase hex SHA-256 of the content"))
		return
	case hex.EncodeToString(sum[:]) != body.ContentSHA256:
		writeError(w, r, newError(http.StatusBadRequest, "digest_mismatch",
			"the content that arrived does not hash to content_sha256, so it was changed on the way; send it again"))
		return
	}

	// A write holds the world volume for its Job's lifetime (internal/maintenance);
	// reads and listings do not. A read-only mount cannot hurt a server starting
@@ -633,6 +659,12 @@ func writeFileEditError(w http.ResponseWriter, r *http.Request, err error) {
		writeError(w, r, newError(http.StatusInsufficientStorage, "volume_full", "%s", err.Error()))
	case errors.Is(err, fileedit.ErrExists):
		writeError(w, r, newError(http.StatusConflict, "file_exists", "%s", err.Error()))
	case errors.Is(err, fileedit.ErrDigestMismatch):
		// A write's content reached its Job changed; nothing was written.
		writeError(w, r, newError(http.StatusBadRequest, "digest_mismatch", "%s", err.Error()))
	case errors.Is(err, fileedit.ErrReadDamaged):
		writeError(w, r, newError(http.StatusBadGateway, "read_damaged",
			"the file's bytes changed on their way from the file Job; read it again"))
	case errors.Is(err, context.DeadlineExceeded):
		writeError(w, r, newError(http.StatusGatewayTimeout, "files_timeout",
			"the file operation did not finish in time; retry shortly"))
+68 −11
Changes for internal/api/handlers_files_test.go: 68 added lines, 11 removed lines.
Original line number Diff line number Diff line
@@ -180,7 +180,7 @@ func TestFileEditorStoppedGate(t *testing.T) {
	}{
		{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
		{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`},
		{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
		{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
		{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
@@ -237,7 +237,7 @@ func TestFileEditorWorldVolumeGate(t *testing.T) {
	}{
		{"list", "GET", "/api/v1/servers/survival/files?path=config", ""},
		{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`},
		{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
		{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
		{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
@@ -278,7 +278,7 @@ func TestFileEditorAuthorization(t *testing.T) {
	}{
		{"list", "GET", "/api/v1/servers/survival/files", ""},
		{"read", "GET", "/api/v1/servers/survival/file?path=server.properties", ""},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk="}`},
		{"write", "PUT", "/api/v1/servers/survival/file?path=server.properties", `{"content":"aGk=","content_sha256":"` + hiSum + `"}`},
		{"mkdir", "POST", "/api/v1/servers/survival/files/mkdir?path=plugins", ""},
		{"delete", "DELETE", "/api/v1/servers/survival/file?path=old.jar", ""},
		{"rename", "POST", "/api/v1/servers/survival/files/rename?path=a.txt", `{"to":"b.txt"}`},
@@ -439,11 +439,14 @@ func TestFileEditorHandlers(t *testing.T) {
			Path       string `json:"path"`
			Content    []byte `json:"content"`
			SHA256     string `json:"sha256"`
			Content256 string `json:"content_sha256"`
		}
		if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
			t.Fatalf("body not JSON: %v", err)
		}
		if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum {
		// content_sha256 is of the bytes sent, so the panel can check what arrived.
		if resp.Path != "server.properties" || string(resp.Content) != "motd=hello\n" || resp.SHA256 != testSum ||
			resp.Content256 != hexSum([]byte("motd=hello\n")) {
			t.Fatalf("unexpected response %+v (%q)", resp, resp.Content)
		}
	})
@@ -465,7 +468,7 @@ func TestFileEditorHandlers(t *testing.T) {
		api.External = staticExternal{p: owner}

		w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
			`{"content":"bW90ZD1jaGFuZ2VkCg=="}`, jsonHeader)
			`{"content":"bW90ZD1jaGFuZ2VkCg==","content_sha256":"`+hexSum([]byte("motd=changed\n"))+`"}`, jsonHeader)
		if w.Code != http.StatusOK {
			t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
		}
@@ -483,7 +486,7 @@ func TestFileEditorHandlers(t *testing.T) {
		files.sum = strings.Repeat("b", 64)
		api.External = staticExternal{p: owner}
		w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
			`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
			`{"content":"aGk=","content_sha256":"`+hiSum+`","expect_sha256":"`+testSum+`"}`, jsonHeader)
		if w.Code != http.StatusOK {
			t.Fatalf("code = %d (%s)", w.Code, w.Body.String())
		}
@@ -516,7 +519,7 @@ func TestFileEditorHandlers(t *testing.T) {
		files.err = fmt.Errorf("%w: server.properties has changed", fileedit.ErrConflict)
		api.External = staticExternal{p: owner}
		w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
			`{"content":"aGk=","expect_sha256":"`+testSum+`"}`, jsonHeader)
			`{"content":"aGk=","content_sha256":"`+hiSum+`","expect_sha256":"`+testSum+`"}`, jsonHeader)
		if w.Code != http.StatusConflict || decodeErr(t, w) != "file_changed" {
			t.Fatalf("code = %d body %s, want 409 file_changed", w.Code, w.Body.String())
		}
@@ -577,7 +580,7 @@ func TestFileEditorHandlers(t *testing.T) {
		api, _, _, files := mkFiles(t)
		api.External = staticExternal{p: owner}
		w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
			`{"content":""}`, jsonHeader)
			`{"content":"","content_sha256":"`+hexSum(nil)+`"}`, jsonHeader)
		if w.Code != http.StatusOK {
			t.Fatalf("code = %d, want 200 (%s)", w.Code, w.Body.String())
		}
@@ -590,7 +593,8 @@ func TestFileEditorHandlers(t *testing.T) {
	t.Run("a write at exactly the limit is allowed", func(t *testing.T) {
		api, _, _, files := mkFiles(t)
		api.External = staticExternal{p: owner}
		body, err := json.Marshal(writeFileRequest{Content: bytesPtr(make([]byte, fileedit.MaxWriteBytes))})
		content := make([]byte, fileedit.MaxWriteBytes)
		body, err := json.Marshal(writeFileRequest{Content: &content, ContentSHA256: hexSum(content)})
		if err != nil {
			t.Fatalf("marshal: %v", err)
		}
@@ -738,12 +742,12 @@ func TestFileManagerHandlers(t *testing.T) {
		api, _, _, files := mkFiles(t)
		api.External = staticExternal{p: owner}
		w := do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=plugins/new.yml",
			`{"content":"","create_only":true}`, jsonHeader)
			`{"content":"","content_sha256":"`+hexSum(nil)+`","create_only":true}`, jsonHeader)
		if w.Code != http.StatusOK || !files.gotCreateOnly || files.gotExpect != "" {
			t.Fatalf("code = %d, createOnly = %v, expect = %q (%s)", w.Code, files.gotCreateOnly, files.gotExpect, w.Body.String())
		}
		w = do(api.ExternalHandler(), "PUT", "/api/v1/servers/survival/file?path=server.properties",
			`{"content":"aGk="}`, jsonHeader)
			`{"content":"aGk=","content_sha256":"`+hiSum+`"}`, jsonHeader)
		if w.Code != http.StatusOK || files.gotCreateOnly {
			t.Fatalf("a plain save: code = %d, createOnly = %v", w.Code, files.gotCreateOnly)
		}
@@ -766,6 +770,58 @@ func contentDigestOf(body string) string {
	return "sha-256=:" + base64.StdEncoding.EncodeToString(sum[:]) + ":"
}

// hexSum is the content_sha256 a client sends with a write of b; hiSum is that
// of "hi" (aGk=).
func hexSum(b []byte) string {
	sum := sha256.Sum256(b)
	return hex.EncodeToString(sum[:])
}

var hiSum = hexSum([]byte("hi"))

// A write carries the SHA-256 of its content: one without it, with a malformed
// one, or whose content hashes otherwise is refused before a Job starts, and a
// Job that found the bytes it received changed answers the same way. None of
// them is audited.
func TestWriteFileChecksTheContentDigest(t *testing.T) {
	owner := &Principal{UserID: "owner1", Email: "[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
// way a client that streams or lies would send it. Its Content-Digest is that
// of the body.
@@ -1063,6 +1119,7 @@ func TestFileEditorErrorMapping(t *testing.T) {
		{"changed since read", fmt.Errorf("%w: nope", fileedit.ErrConflict), http.StatusConflict, "file_changed"},
		{"volume full", fmt.Errorf("%w: nope", fileedit.ErrNoSpace), http.StatusInsufficientStorage, "volume_full"},
		{"already there", fmt.Errorf("%w: nope", fileedit.ErrExists), http.StatusConflict, "file_exists"},
		{"changed on the way from the Job", fmt.Errorf("%w: nope", fileedit.ErrReadDamaged), http.StatusBadGateway, "read_damaged"},
		{"timeout", fmt.Errorf("waiting: %w", context.DeadlineExceeded), http.StatusGatewayTimeout, "files_timeout"},
	}

Loading