fix(files): 编辑器保存和打开也逐跳核对 SHA-256,途中变了的内容不写盘也不打开
This commit is contained in:
18 files changed
+369
-57
No files matched your search
@@ -72,9 +72,10 @@
|
||||
"upload_incomplete": "The upload stopped before the whole file arrived, so nothing was changed. Try again.",
|
||||
"length_required": "The upload did not say how large it is, so it was refused. Upload it again from the panel.",
|
||||
"digest_mismatch": "The file was changed on its way to the server, so it was refused and nothing was written. Try again.",
|
||||
"digest_required": "The upload came without a checksum, so it was refused. Reload the panel and upload it again.",
|
||||
"bad_digest": "The upload's checksum was malformed, so it was refused. Reload the panel and upload it again.",
|
||||
"digest_required": "The request came without a checksum, so it was refused. Reload the panel and try again.",
|
||||
"bad_digest": "The request's checksum was malformed, so it was refused. Reload the panel and try again.",
|
||||
"file_unreadable": "The file could not be read: it was changed, moved or deleted after it was picked. Pick it again and upload.",
|
||||
"read_damaged": "The file arrived damaged, so it was not opened: saving it would write the damage back. Open it again.",
|
||||
"upload_not_found": "This upload is gone: it was cancelled, already landed, sat idle for 6 hours, or the panel service restarted. Upload the file again.",
|
||||
"too_many_uploads": "You already have 4 large uploads in progress. Wait for one to finish, or cancel one, and try again.",
|
||||
"op_lost": "The operation's progress can no longer be read. Refresh the list to see whether the file landed.",
|
||||
|
||||
@@ -72,9 +72,10 @@
|
||||
"upload_incomplete": "文件还没传完上传就中断了,什么都没有改动。请重试。",
|
||||
"length_required": "这次上传没有声明文件大小,被拒绝了。请从面板重新上传。",
|
||||
"digest_mismatch": "文件在传输途中被改动了,服务器已拒收,什么都没有写入。请重试。",
|
||||
"digest_required": "这次上传没有附带校验值,被拒绝了。请刷新面板后重新上传。",
|
||||
"bad_digest": "这次上传附带的校验值格式不对,被拒绝了。请刷新面板后重新上传。",
|
||||
"digest_required": "这次请求没有附带校验值,被拒绝了。请刷新面板后再试。",
|
||||
"bad_digest": "这次请求附带的校验值格式不对,被拒绝了。请刷新面板后再试。",
|
||||
"file_unreadable": "读不出这个文件:它在选中之后被改动、移走或删除了。请重新选择文件再上传。",
|
||||
"read_damaged": "文件传过来时损坏了,所以没有打开:保存它会把损坏写回去。请重新打开。",
|
||||
"upload_not_found": "这次分片上传已经不在了(取消过、已经写入、闲置超过 6 小时,或者面板服务重启过)。请重新上传。",
|
||||
"too_many_uploads": "你同时进行的大文件上传已经有 4 个了。等其中一个完成,或者取消一个再试。",
|
||||
"op_lost": "看不到这次操作的进度了。刷新列表看看文件有没有写入。",
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
// hexOf is the SHA-256 (hex) of the bytes a file editor call carries.
|
||||
const hexOf = (bytes: string | Uint8Array) => createHash("sha256").update(bytes).digest("hex");
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
|
||||
|
||||
// Pin the GET /me wire shape. is_admin crosses an untyped fetch().json() boundary
|
||||
@@ -853,15 +856,29 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
});
|
||||
|
||||
it("readServerFile GETs /servers/{name}/file and passes base64 through", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC" });
|
||||
const content_sha256 = hexOf(new Uint8Array([0, 1, 2]));
|
||||
const fetchSpy = fakeFetch({ path: "world/level.dat", content: "AAEC", sha256: "a".repeat(64), content_sha256 });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
const res = await api.readServerFile("survival", "world/level.dat");
|
||||
expect(res.content).toBe("AAEC");
|
||||
expect(res).toEqual({ path: "world/level.dat", content: "AAEC", sha256: "a".repeat(64), content_sha256 });
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/servers/survival/file?path=world%2Flevel.dat");
|
||||
expect((opts as RequestInit).method).toBe("GET");
|
||||
});
|
||||
|
||||
// An editor that opened a damaged read would save the damage back.
|
||||
it.each([
|
||||
["hash otherwise", "AAED"],
|
||||
["are not base64 at all", "AA=E"],
|
||||
])("readServerFile refuses content whose bytes %s", async (_, content) => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
fakeFetch({ path: "world/level.dat", content, sha256: "a".repeat(64), content_sha256: hexOf(new Uint8Array([0, 1, 2])) }),
|
||||
);
|
||||
await expect(api.readServerFile("survival", "world/level.dat")).rejects.toMatchObject({ code: "read_damaged" });
|
||||
expect(humanizeError({ code: "read_damaged" })).toMatch(/arrived damaged/);
|
||||
});
|
||||
|
||||
it("writeServerFile PUTs {content} — an explicit \"\" is a deliberate truncate, not an omitted field", async () => {
|
||||
const fetchSpy = fakeFetch({ path: "a.txt", status: "written" });
|
||||
vi.stubGlobal("fetch", fetchSpy);
|
||||
@@ -870,7 +887,7 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
const [url, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect(String(url)).toBe("/servers/survival/file?path=a.txt");
|
||||
expect((opts as RequestInit).method).toBe("PUT");
|
||||
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "" }));
|
||||
expect((opts as RequestInit).body).toBe(JSON.stringify({ content: "", content_sha256: hexOf("") }));
|
||||
});
|
||||
|
||||
it("writeServerFile sends the hash the read returned as expect_sha256", async () => {
|
||||
@@ -880,7 +897,7 @@ describe("image whitelist and builds wire shapes", () => {
|
||||
expect(res.sha256).toBe("b".repeat(64));
|
||||
const [, opts] = (fetchSpy as unknown as ReturnType<typeof vi.fn>).mock.calls[0];
|
||||
expect((opts as RequestInit).body).toBe(
|
||||
JSON.stringify({ content: "aGk=", expect_sha256: "a".repeat(64) }),
|
||||
JSON.stringify({ content: "aGk=", content_sha256: hexOf("hi"), expect_sha256: "a".repeat(64) }),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -1372,7 +1389,7 @@ describe("server file manager wire shapes", () => {
|
||||
const [url, opts] = sent(fetchSpy);
|
||||
expect(url).toBe("/servers/survival/file?path=plugins%2Fnew.yml");
|
||||
expect(opts.method).toBe("PUT");
|
||||
expect(opts.body).toBe(JSON.stringify({ content: "", create_only: true }));
|
||||
expect(opts.body).toBe(JSON.stringify({ content: "", content_sha256: hexOf(""), create_only: true }));
|
||||
});
|
||||
|
||||
it("deleteServerFile DELETEs /servers/{name}/file with no body", async () => {
|
||||
|
||||
+29
-8
@@ -45,7 +45,7 @@ import type {
|
||||
UpdateReport,
|
||||
} from "./types";
|
||||
import { loadConfig } from "./config";
|
||||
import { sha256Of } from "./digest";
|
||||
import { base64Sha256, sha256Of } from "./digest";
|
||||
import i18next from "i18next";
|
||||
|
||||
// Typed client for the felis-api external face (spec §7). Credentials are sent so
|
||||
@@ -768,31 +768,49 @@ export const api = rejectingSync({
|
||||
// readServerFile returns one file's bytes (base64) and the sha256 of the file
|
||||
// as stored. A file over the read ceiling is a 413, never a silent truncation,
|
||||
// because a later save of a truncated body would destroy the rest of the file.
|
||||
readServerFile: (name: string, path: string) =>
|
||||
request<{ path: string; content: string; sha256: string }>(
|
||||
// The content must hash to content_sha256, the digest of the bytes as sent: a
|
||||
// read damaged on the way is refused (read_damaged) rather than opened, since
|
||||
// saving it would write the damage back.
|
||||
readServerFile: async (name: string, path: string) => {
|
||||
const r = await request<{ path: string; content: string; sha256: string; content_sha256: string }>(
|
||||
"GET",
|
||||
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
|
||||
),
|
||||
);
|
||||
let got = "";
|
||||
try {
|
||||
got = await base64Sha256(r.content);
|
||||
} catch {
|
||||
/* not base64 at all: damaged too */
|
||||
}
|
||||
if (got !== r.content_sha256) throw clientError("read_damaged");
|
||||
return r;
|
||||
},
|
||||
|
||||
// writeServerFile atomically replaces a file's contents (creating it if
|
||||
// absent). Sending an explicit "" is a deliberate truncate; the wire field is
|
||||
// required, but that is enforced by the caller (this method always sends one).
|
||||
// With expectSha256 (the hash the read returned) a file someone changed since
|
||||
// is refused with 409 file_changed; without it the write is unconditional.
|
||||
writeServerFile: (name: string, path: string, content: string, expectSha256?: string) =>
|
||||
// The content goes with its SHA-256 (content_sha256), so content changed on
|
||||
// the way is refused (digest_mismatch) and nothing is written.
|
||||
writeServerFile: async (name: string, path: string, content: string, expectSha256?: string) =>
|
||||
request<{ path: string; status: string; sha256: string }>(
|
||||
"PUT",
|
||||
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
|
||||
expectSha256 ? { content, expect_sha256: expectSha256 } : { content },
|
||||
{
|
||||
content,
|
||||
content_sha256: await base64Sha256(content),
|
||||
...(expectSha256 ? { expect_sha256: expectSha256 } : {}),
|
||||
},
|
||||
),
|
||||
|
||||
// createServerFile makes a new file with content, and only if nothing is at the
|
||||
// path yet: something that appeared meanwhile is 409 file_exists, never replaced.
|
||||
createServerFile: (name: string, path: string, content: string) =>
|
||||
createServerFile: async (name: string, path: string, content: string) =>
|
||||
request<{ path: string; status: string; sha256: string }>(
|
||||
"PUT",
|
||||
urlPath`/servers/${name}/file` + `?path=${encodeURIComponent(path)}`,
|
||||
{ content, create_only: true },
|
||||
{ content, content_sha256: await base64Sha256(content), create_only: true },
|
||||
),
|
||||
|
||||
// deleteServerFile deletes a file, a link (never what it names) or a folder with
|
||||
@@ -1388,6 +1406,9 @@ export function humanizeError(e: unknown): string {
|
||||
return t("bad_digest");
|
||||
case "file_unreadable":
|
||||
return t("file_unreadable");
|
||||
// A file opened in the editor whose bytes arrived damaged (readServerFile).
|
||||
case "read_damaged":
|
||||
return t("read_damaged");
|
||||
// An upload sent in parts: the session is gone (cancelled, landed, idle for
|
||||
// 6 hours, or felis-api restarted), or the account holds four already.
|
||||
case "upload_not_found":
|
||||
|
||||
@@ -22,3 +22,13 @@ export async function sha256Of(blob: Blob): Promise<{ hex: string; header: strin
|
||||
}
|
||||
return { hex, header: `sha-256=:${btoa(bin)}:` };
|
||||
}
|
||||
|
||||
// base64Sha256 answers the hex SHA-256 of the bytes b64 encodes: the file
|
||||
// editor's content_sha256, which a save sends with its content and a read is
|
||||
// checked against. A string that is not base64 throws.
|
||||
export async function base64Sha256(b64: string): Promise<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"];
|
||||
/**
|
||||
* Write a file in a server's world volume (owner-or-admin; server must be stopped).
|
||||
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. Audited as file.write.
|
||||
* @description Replaces a file's contents, creating the file if absent but never creating its parent directories. Content is base64 so arbitrary bytes (CRLF endings, a BOM) survive intact. Writes are capped at 256 KiB — the Job spec carries the content, and etcd bounds the object — so a larger body is 413. Same stopped-gate and os.Root containment as the read; a write through a symlink leaving the world root is refused. The replacement is atomic (a synced temporary sibling renamed over the file, keeping its mode), so a failed write leaves the old file whole. With expect_sha256 the write lands only if the file still has that hash; otherwise 409 file_changed. content_sha256 is the SHA-256 of the content: content that hashes otherwise changed on the way and is refused (400 digest_mismatch) before a Job starts, and the Job checks the bytes it received the same way before writing. Audited as file.write.
|
||||
*/
|
||||
put: operations["writeServerFile"];
|
||||
post?: never;
|
||||
@@ -6921,6 +6921,8 @@ export interface operations {
|
||||
content: string;
|
||||
/** @description SHA-256 of the file as stored (before the rcon.password redaction in server.properties). Send it back as expect_sha256 on the next write. */
|
||||
sha256: string;
|
||||
/** @description SHA-256 of the decoded content as sent (after any redaction). A client that gets content hashing otherwise got it damaged on the way, and reads it again. */
|
||||
content_sha256: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
@@ -6962,6 +6964,15 @@ export interface operations {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
/** @description The file's bytes do not hash to the digest the file Job sent with them (read_damaged): they changed on the way to felis-api. Read it again. */
|
||||
502: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
};
|
||||
content: {
|
||||
"application/json": components["schemas"]["Error"];
|
||||
};
|
||||
};
|
||||
503: components["responses"]["ServiceUnavailable"];
|
||||
/** @description The file Job did not finish in time; retry. */
|
||||
504: {
|
||||
@@ -6995,6 +7006,8 @@ export interface operations {
|
||||
* @description Base64-encoded file bytes.
|
||||
*/
|
||||
content: string;
|
||||
/** @description The SHA-256 (lowercase hex) of the decoded content. Absent is 400 digest_required, malformed 400 bad_digest, and content that does not hash to it 400 digest_mismatch; nothing is written. */
|
||||
content_sha256: string;
|
||||
/** @description The sha256 a read returned. When present, the write is refused with 409 file_changed if the file has changed (or been deleted) since. Omit it to write unconditionally. */
|
||||
expect_sha256?: string;
|
||||
/** @description true writes only if nothing is at the path yet (409 file_exists otherwise), for making a new file without replacing one that appeared meanwhile. Cannot be combined with expect_sha256. */
|
||||
@@ -7018,7 +7031,7 @@ export interface operations {
|
||||
};
|
||||
};
|
||||
};
|
||||
/** @description Missing path, malformed body, invalid server name, or a path that escapes the world root. */
|
||||
/** @description Missing path, malformed body, invalid server name, or a path that escapes the world root (bad_request, bad_path), or content that came without its SHA-256 (digest_required), with a malformed one (bad_digest), or changed on the way (digest_mismatch). */
|
||||
400: {
|
||||
headers: {
|
||||
[name: string]: unknown;
|
||||
|
||||
Reference in new issue
Block a user