feat(ops): add integrity-checked backups and isolated restore drills
This commit is contained in:
1 parent
7867bf6b72
commit
46f7ad6571
10 files changed
+1309
-1
No files matched your search
@@ -0,0 +1,167 @@
|
||||
import {
|
||||
mkdir,
|
||||
mkdtemp,
|
||||
readdir,
|
||||
readFile,
|
||||
rm,
|
||||
symlink,
|
||||
writeFile,
|
||||
} from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, expect, it } from "vitest";
|
||||
import { createArtifact, restoreFiles, verifyArtifact } from "./core.mjs";
|
||||
|
||||
let directory;
|
||||
let roots;
|
||||
let output;
|
||||
const database = { database: "fixture", objects: [], tables: [] };
|
||||
const dump = async (target) => {
|
||||
await writeFile(target, "CREATE DATABASE fixture;\n");
|
||||
return database;
|
||||
};
|
||||
beforeEach(async () => {
|
||||
directory = await mkdtemp(path.join(tmpdir(), "cms-backup-test-"));
|
||||
roots = Object.fromEntries(
|
||||
["storage", "nitro", "swf"].map((key) => [key, path.join(directory, key)]),
|
||||
);
|
||||
for (const root of Object.values(roots)) await mkdir(root);
|
||||
await mkdir(path.join(roots.storage, "empty"));
|
||||
await writeFile(
|
||||
path.join(roots.storage, "image.bin"),
|
||||
Buffer.from([0, 255, 128, 1]),
|
||||
);
|
||||
await writeFile(
|
||||
path.join(roots.nitro, "furniture.json"),
|
||||
'{"caption":"Caffè 🏨"}',
|
||||
);
|
||||
output = path.join(directory, "artifact");
|
||||
});
|
||||
afterEach(async () => {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("copies exact bytes and empty directories, records hashes without source paths, and restores only into a new directory", async () => {
|
||||
const manifest = await createArtifact({ roots, output }, dump);
|
||||
expect(manifest.complete).toBe(true);
|
||||
expect(JSON.stringify(manifest)).not.toContain(directory);
|
||||
expect(
|
||||
manifest.entries.find((entry) => entry.path === "files/storage/image.bin")
|
||||
.sha256,
|
||||
).toMatch(/^[a-f0-9]{64}$/);
|
||||
expect(await verifyArtifact(output)).toEqual(manifest);
|
||||
const restored = path.join(directory, "restored");
|
||||
await restoreFiles(output, restored);
|
||||
expect(await readFile(path.join(restored, "storage/image.bin"))).toEqual(
|
||||
Buffer.from([0, 255, 128, 1]),
|
||||
);
|
||||
expect(await readdir(path.join(restored, "storage/empty"))).toEqual([]);
|
||||
await expect(restoreFiles(output, restored)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it.each(["files/storage/image.bin", "database.sql"])(
|
||||
"rejects modified %s before restoring files",
|
||||
async (entry) => {
|
||||
await createArtifact({ roots, output }, dump);
|
||||
await writeFile(path.join(output, entry), "tampered");
|
||||
const restored = path.join(directory, "restored");
|
||||
await expect(restoreFiles(output, restored)).rejects.toThrow();
|
||||
await expect(readdir(restored)).rejects.toThrow();
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects unlisted files and malicious manifest traversal", async () => {
|
||||
await createArtifact({ roots, output }, dump);
|
||||
const extra = path.join(output, "unexpected.txt");
|
||||
await writeFile(extra, "extra");
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
await rm(extra);
|
||||
const manifest = JSON.parse(
|
||||
await readFile(path.join(output, "manifest.json"), "utf8"),
|
||||
);
|
||||
manifest.entries[0].path = "../escape";
|
||||
await writeFile(path.join(output, "manifest.json"), JSON.stringify(manifest));
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects an incomplete artifact and leaves no final artifact after dump failure", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots, output }, async () => {
|
||||
throw Error("database failed");
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(readdir(output)).rejects.toThrow();
|
||||
await mkdir(output);
|
||||
await writeFile(path.join(output, "database.sql"), "partial");
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("refuses overwrites and output inside a source root", async () => {
|
||||
await mkdir(output);
|
||||
await writeFile(path.join(output, "keep"), "original");
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
expect(await readFile(path.join(output, "keep"), "utf8")).toBe("original");
|
||||
await expect(
|
||||
createArtifact({ roots, output: path.join(roots.storage, "backup") }, dump),
|
||||
).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects omitted roots, overlapping roots, and secret configuration files", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots: { storage: roots.storage }, output }, dump),
|
||||
).rejects.toThrow();
|
||||
await expect(
|
||||
createArtifact({ roots: { ...roots, nitro: roots.storage }, output }, dump),
|
||||
).rejects.toThrow();
|
||||
await writeFile(path.join(roots.storage, ".env"), "DATABASE_URL=secret");
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects symbolic links in source trees and artifact trees", async () => {
|
||||
const outside = path.join(directory, "outside");
|
||||
await mkdir(outside);
|
||||
await writeFile(path.join(outside, "secret"), "private");
|
||||
const link = path.join(roots.storage, "linked");
|
||||
await symlink(
|
||||
outside,
|
||||
link,
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await expect(createArtifact({ roots, output }, dump)).rejects.toThrow();
|
||||
await rm(link);
|
||||
await createArtifact({ roots, output }, dump);
|
||||
await symlink(
|
||||
outside,
|
||||
path.join(output, "files/storage/linked"),
|
||||
process.platform === "win32" ? "junction" : "dir",
|
||||
);
|
||||
await expect(verifyArtifact(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("rejects files changed while the database is being dumped", async () => {
|
||||
await expect(
|
||||
createArtifact({ roots, output }, async (target) => {
|
||||
await writeFile(
|
||||
path.join(roots.storage, "image.bin"),
|
||||
"changed during backup",
|
||||
);
|
||||
return dump(target);
|
||||
}),
|
||||
).rejects.toThrow();
|
||||
await expect(readdir(output)).rejects.toThrow();
|
||||
});
|
||||
|
||||
it("restores a directory whose prefix also appears in a sibling filename", async () => {
|
||||
await mkdir(path.join(roots.storage, "foo"));
|
||||
await writeFile(path.join(roots.storage, "foo/file"), "nested");
|
||||
await writeFile(path.join(roots.storage, "foo.json"), "sibling");
|
||||
await createArtifact({ roots, output }, dump);
|
||||
const destination = path.join(directory, "restored");
|
||||
await restoreFiles(output, destination);
|
||||
expect(
|
||||
await readFile(path.join(destination, "storage/foo/file"), "utf8"),
|
||||
).toBe("nested");
|
||||
expect(
|
||||
await readFile(path.join(destination, "storage/foo.json"), "utf8"),
|
||||
).toBe("sibling");
|
||||
});
|
||||
Reference in new issue
Block a user