168 lines
5.8 KiB
JavaScript
168 lines
5.8 KiB
JavaScript
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");
|
|
});
|