265 lines
9.1 KiB
TypeScript
265 lines
9.1 KiB
TypeScript
import {
|
|
lstat,
|
|
open,
|
|
readFile,
|
|
rename,
|
|
symlink,
|
|
unlink,
|
|
utimes,
|
|
writeFile,
|
|
} from "node:fs/promises";
|
|
import { DatabaseSync } from "node:sqlite";
|
|
import { expect, it, onTestFinished, vi } from "vitest";
|
|
import { ArtifactResourceScope } from "../../../src/artifacts/ArtifactResourceScope.js";
|
|
import type { StableArtifactFileSystem } from "../../../src/artifacts/readStableArtifact.js";
|
|
import { captureSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseSnapshot.js";
|
|
import { inspectSqliteDatabaseSnapshot } from "../../../src/sqlite/SqliteDatabaseInspection.js";
|
|
import { sqliteDatabaseFailure } from "../../../src/sqlite/SqliteDatabaseFailures.js";
|
|
import {
|
|
createSqliteDatabaseFixture,
|
|
SQLITE_NATIVE_LIMITS_AVAILABLE,
|
|
} from "../../fixtures/sqlite/database.js";
|
|
import {
|
|
createTestWorkspace,
|
|
removeTestWorkspace,
|
|
} from "../../support/workspace/workspaceFixture.js";
|
|
|
|
const fixture = async (wal = false) => {
|
|
const workspace = await createTestWorkspace("rea-sqlite-snapshot-race-");
|
|
const database = createSqliteDatabaseFixture(workspace.root, wal);
|
|
if (!wal) database.close();
|
|
const resourceScope = new ArtifactResourceScope();
|
|
onTestFinished(async () => {
|
|
await resourceScope.close();
|
|
if (wal) database.close();
|
|
await removeTestWorkspace(workspace.root);
|
|
});
|
|
const root = await workspace.mkdir("private");
|
|
const capture = (fileSystem?: StableArtifactFileSystem) =>
|
|
captureSqliteDatabaseSnapshot(
|
|
database.path,
|
|
root,
|
|
resourceScope,
|
|
undefined,
|
|
fileSystem,
|
|
);
|
|
return { workspace, database, root, capture };
|
|
};
|
|
|
|
it.runIf(SQLITE_NATIVE_LIMITS_AVAILABLE).each(["PERSIST", "TRUNCATE"])(
|
|
"inspects a committed %s journal without modifying the source file set",
|
|
async (mode) => {
|
|
const { database, capture } = await fixture();
|
|
const writer = new DatabaseSync(database.path);
|
|
try {
|
|
writer.exec(
|
|
`PRAGMA journal_mode=${mode}; INSERT INTO records(id, text_value) VALUES (4, 'committed');`,
|
|
);
|
|
} finally {
|
|
writer.close();
|
|
}
|
|
const journalPath = `${database.path}-journal`;
|
|
const before = await readFile(journalPath);
|
|
const beforeStat = await lstat(journalPath, { bigint: true });
|
|
const snapshot = await capture();
|
|
const result = inspectSqliteDatabaseSnapshot(snapshot.snapshotPath, {
|
|
path: database.path,
|
|
table: "records",
|
|
});
|
|
expect(result.rows).toMatchObject({ returned_rows: 4, truncated: false });
|
|
expect(await readFile(journalPath)).toEqual(before);
|
|
const afterStat = await lstat(journalPath, { bigint: true });
|
|
expect([
|
|
afterStat.ino,
|
|
afterStat.size,
|
|
afterStat.mtimeNs,
|
|
afterStat.ctimeNs,
|
|
]).toEqual([
|
|
beforeStat.ino,
|
|
beforeStat.size,
|
|
beforeStat.mtimeNs,
|
|
beforeStat.ctimeNs,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform !== "win32").each([
|
|
["database", "remove"],
|
|
["WAL", "remove"],
|
|
["database", "replace with symlink"],
|
|
["WAL", "replace with symlink"],
|
|
])(
|
|
"reports an observed %s change during acquisition as changed evidence: %s",
|
|
async (member, change) => {
|
|
const { database, workspace, capture } = await fixture(true);
|
|
const replacement = await workspace.write("replacement", "");
|
|
const selectedPath =
|
|
member === "database" ? database.path : database.walPath;
|
|
let observations = 0;
|
|
const fileSystem: StableArtifactFileSystem = {
|
|
lstat: async (path) => {
|
|
if (path === selectedPath && ++observations === 2) {
|
|
await unlink(path);
|
|
if (change === "replace with symlink")
|
|
await symlink(replacement, path);
|
|
}
|
|
return lstat(path, { bigint: true });
|
|
},
|
|
open,
|
|
};
|
|
try {
|
|
await capture(fileSystem);
|
|
throw new Error("A disappearing source must be rejected");
|
|
} catch (cause) {
|
|
expect(
|
|
sqliteDatabaseFailure(cause, database.path, "artifact-read"),
|
|
).toMatchObject({ _tag: "AnalysisArtifactChangedError" });
|
|
expect(cause).toMatchObject({
|
|
message: expect.stringContaining(selectedPath),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
|
|
it("refuses an active journal even before SQLite writes its magic header", async () => {
|
|
const { database, capture } = await fixture();
|
|
const writer = new DatabaseSync(database.path);
|
|
try {
|
|
writer.exec(
|
|
"PRAGMA journal_mode=PERSIST; BEGIN IMMEDIATE; INSERT INTO records(id, text_value) VALUES (4, 'uncommitted');",
|
|
);
|
|
const journalPath = `${database.path}-journal`;
|
|
const journal = await readFile(journalPath);
|
|
expect(journal.subarray(0, 8).every((byte) => byte === 0)).toBe(true);
|
|
expect(journal.subarray(8, 28).some((byte) => byte !== 0)).toBe(true);
|
|
const original = await readFile(database.path);
|
|
await expect(capture()).rejects.toMatchObject({
|
|
reason: "format",
|
|
message: expect.stringContaining("recovery"),
|
|
});
|
|
expect(await readFile(database.path)).toEqual(original);
|
|
expect(await readFile(journalPath)).toEqual(journal);
|
|
} finally {
|
|
writer.exec("ROLLBACK");
|
|
writer.close();
|
|
}
|
|
});
|
|
|
|
it("refuses an incomplete zeroed journal header", async () => {
|
|
const { database, capture } = await fixture();
|
|
await writeFile(`${database.path}-journal`, Buffer.alloc(27));
|
|
await expect(capture()).rejects.toMatchObject({ reason: "format" });
|
|
});
|
|
|
|
it.runIf(process.platform !== "win32")(
|
|
"refuses a journal symlink even when its target is empty",
|
|
async () => {
|
|
const { database, workspace, capture } = await fixture();
|
|
const empty = await workspace.write("empty-journal", "");
|
|
await symlink(empty, `${database.path}-journal`);
|
|
await expect(capture()).rejects.toMatchObject({ reason: "path" });
|
|
},
|
|
);
|
|
|
|
it.each(["activate", "replace", "remove"])(
|
|
"rejects a cold journal changed during database acquisition: %s",
|
|
async (change) => {
|
|
const { database, capture } = await fixture();
|
|
const journalPath = `${database.path}-journal`;
|
|
await writeFile(journalPath, Buffer.alloc(28));
|
|
const fileSystem: StableArtifactFileSystem = {
|
|
lstat: (path) => lstat(path, { bigint: true }),
|
|
open: async (path, flags) => {
|
|
if (path === database.path) {
|
|
if (change === "activate")
|
|
await writeFile(journalPath, Buffer.alloc(28, 1));
|
|
else if (change === "remove") await unlink(journalPath);
|
|
else {
|
|
await rename(journalPath, `${journalPath}.previous`);
|
|
await writeFile(journalPath, Buffer.alloc(28));
|
|
}
|
|
}
|
|
return open(path, flags);
|
|
},
|
|
};
|
|
await expect(capture(fileSystem)).rejects.toMatchObject({
|
|
reason: "integrity",
|
|
});
|
|
},
|
|
);
|
|
|
|
it("refuses an activated journal even when filesystem metadata cannot distinguish the write", async () => {
|
|
const { database, capture } = await fixture();
|
|
const journalPath = `${database.path}-journal`;
|
|
await writeFile(journalPath, Buffer.alloc(28));
|
|
const observed = await lstat(journalPath, { bigint: true });
|
|
const fileSystem: StableArtifactFileSystem = {
|
|
lstat: (path) =>
|
|
path === journalPath
|
|
? Promise.resolve(observed)
|
|
: lstat(path, { bigint: true }),
|
|
open: async (path, flags) => {
|
|
if (path === database.path)
|
|
await writeFile(journalPath, Buffer.alloc(28, 1));
|
|
const handle = await open(path, flags);
|
|
if (path === journalPath)
|
|
vi.spyOn(handle, "stat").mockResolvedValue(observed);
|
|
return handle;
|
|
},
|
|
};
|
|
await expect(capture(fileSystem)).rejects.toMatchObject({
|
|
reason: "integrity",
|
|
});
|
|
});
|
|
|
|
it("rejects a database changed after its copy but before WAL acquisition", async () => {
|
|
const { database, root, capture } = await fixture(true);
|
|
const initial = await readFile(database.path);
|
|
const initialStat = await lstat(database.path);
|
|
let changed = false;
|
|
const fileSystem: StableArtifactFileSystem = {
|
|
lstat: (path) => lstat(path, { bigint: true }),
|
|
open: async (path, flags) => {
|
|
if (path === database.walPath) {
|
|
const bytes = Buffer.from(initial);
|
|
const offset = bytes.length - 1;
|
|
bytes[offset] = (bytes[offset] ?? 0) ^ 1;
|
|
await writeFile(database.path, bytes);
|
|
await utimes(database.path, initialStat.atime, initialStat.mtime);
|
|
changed = true;
|
|
}
|
|
return open(path, flags);
|
|
},
|
|
};
|
|
await expect(capture(fileSystem)).rejects.toMatchObject({
|
|
reason: "integrity",
|
|
});
|
|
expect(changed).toBe(true);
|
|
expect(await readFile(`${root}/database.snapshot`)).toEqual(initial);
|
|
});
|
|
|
|
it.each(["-wal", "-journal"] as const)(
|
|
"rejects a previously absent %s created during database acquisition",
|
|
async (suffix) => {
|
|
const { database, capture } = await fixture();
|
|
let created = false;
|
|
const fileSystem: StableArtifactFileSystem = {
|
|
lstat: (path) => lstat(path, { bigint: true }),
|
|
open: async (path, flags) => {
|
|
if (path === database.path) {
|
|
await writeFile(
|
|
`${database.path}${suffix}`,
|
|
"newly selected sidecar",
|
|
);
|
|
created = true;
|
|
}
|
|
return open(path, flags);
|
|
},
|
|
};
|
|
await expect(capture(fileSystem)).rejects.toMatchObject({
|
|
reason: "integrity",
|
|
});
|
|
expect(created).toBe(true);
|
|
},
|
|
);
|