fix: avoid timeouts for safe ZIP anonymization (#873)

This commit is contained in:
Thomas Durieux authored and GitHub committed 2026-10-08 08:20:26 +01:00
1 parent 40c369641b
commit 4fd31a59c8
7 files changed
+159 -12

No files matched your search

@@ -39,4 +39,31 @@ describe("literal term presence checks", function () {
expect(optimized.wasAnonymized).to.equal(reference.wasAnonymized);
}
});
it("keeps required-prefix absence checks conservative for arbitrary regex syntax", function () {
const cases = [
{ terms: ["Researcher[0-9]+"], text: "absent name Researcher12 Résearcher42" },
{ terms: ["foo?bar"], text: "foobar fobar" },
{ terms: ["foo*bar"], text: "foobar fobar foobarbar" },
{ terms: ["foo{0,2}bar"], text: "fobar foobar fooobar" },
{ terms: ["foo+bar"], text: "foobar fooobar" },
{ terms: ["a?", "[ab]*", "[ab]{0,2}"], text: "x a ab" },
{ terms: ["foo[a]{0}bar", "[ab]?foo"], text: "foobar foo afoo" },
{ terms: ["😀?bar"], text: "bar 😀bar" },
{ terms: ["foo|bar", "foo(bar|baz)"], text: "foo bar foobar foobaz" },
{ terms: ["foo\\|bar", "foo\\.bar"], text: "foo|bar foo.bar" },
{ terms: ["^foo", "(?:foo)?bar", "[A-Z]+foo"], text: "foo bar XXfoo" },
{ terms: ["k[0-9]+", "s[0-9]+"], text: "k12 K12 s34 ſ34 ék12" },
{ terms: ["😀[0-9]+", "研究[0-9]+"], text: "😀12 研究34" },
{ terms: ["Alice=>Researcher12", "Researcher[0-9]+=>Hidden"], text: "Alice" },
{ terms: ["Researcher[0-9]+"], text: "https://host/Researcher12/path" },
];
for (const { terms, text } of cases) {
const reference = new ContentAnonimizer({ terms });
for (const term of reference.compiledTerms) delete term.requiredPrefix;
const optimized = new ContentAnonimizer({ terms });
expect(optimized.anonymize(text), JSON.stringify(terms)).to.equal(reference.anonymize(text));
expect(optimized.wasAnonymized).to.equal(reference.wasAnonymized);
}
});
});
+13
View File
@@ -160,6 +160,19 @@ describe("large notebook downloads", function () {
expect(JSON.parse(entries["example.ipynb"].toString()).cells[0].source[0]).to.equal("# XXXX-1's experiment");
});
it("completes large safe regex entries and continues to the next ZIP entry", async function () {
const payload = "123,456,78.91234,-45.123456\n".repeat(280000);
upstream = Readable.from([await zip({ "root/large.csv": `Alice\n${payload}Alice`, "root/after.txt": "Alice" })]);
const response = new PassThrough(), output = collect(response);
await streamAnonymizedZip({ ...options, anonymizerOptions: {
terms: ["Alice", ...Array.from({ length: 8 }, (_, i) => `Researcher${i}[0-9]+`)],
} }, response);
const entries = await unzip(await output);
expect(entries["large.csv"].toString()).to.equal(`XXXX-1\n${payload}XXXX-1`);
expect(entries["after.txt"].toString()).to.equal("XXXX-1");
expect(response.writableFinished).to.equal(true);
});
it("aborts the response when an entry cannot be anonymized", async function () {
upstream = Readable.from([await zip({ "root/large.txt": "Alice".repeat(1000) })]);
config.MAX_FILE_SIZE = 100;
+38
View File
@@ -59,6 +59,44 @@ describe("large anonymization resources", function () {
const options = { filePath: `recovery-${randomUUID()}.txt`, terms: ["Alice=>Hidden"] };
expect(await transform(text, options)).to.equal(new ContentAnonimizer(options).anonymize(text));
});
it("finishes large files containing many safe regex rules", async () => {
const payload = "123,456,78.91234,-45.123456\n".repeat(280000);
const terms = ["Alice", ...Array.from({ length: 8 }, (_, i) => `Researcher${i}[0-9]+`)];
const output = await transform(`Alice\n${payload}Alice`, {
filePath: `safe-regex-${randomUUID()}.csv`, terms,
});
expect(output).to.equal(`XXXX-1\n${payload}XXXX-1`);
});
it("uses the worker budget for dense Unicode replacements", async () => {
const input = "Álice,123,45.678,-23.456\n".repeat(800000);
expect(await transform(input, { filePath: `dense-unicode-${randomUUID()}.csv`, terms: ["Alice"] }))
.to.equal("XXXX-1,123,45.678,-23.456\n".repeat(800000));
});
it("retains the short deadline for backtracking regexes on a worker and recovers", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "worker-regex-deadline-"));
const input = join(root, "input"), output = join(root, "output");
const controller = new AbortController();
try {
const text = "a".repeat(250);
await fs.writeFile(input, text);
let failure;
const started = Date.now();
try {
await pool.anonymizeOnWorker({ input, output, options: { terms: ["a+a+a+a+a+a+b(?=x)"] },
maxOutput: 1000000, context: { mask: "XXXX", hostname: "localhost" } }, text.length, controller.signal);
} catch (error) { failure = error; }
expect(failure).to.be.instanceOf(Error);
expect(failure.message).to.match(/timed out after 1000ms/);
expect(Date.now() - started).to.be.lessThan(8000);
let exists = true;
try { await fs.access(output); } catch (error) { if (error.code === "ENOENT") exists = false; else throw error; }
expect(exists).to.equal(false);
expect(await transform("Alice ".repeat(50000), { filePath: `regex-recovery-${randomUUID()}.txt`, terms: ["Alice"] }))
.to.equal("XXXX-1 ".repeat(50000));
} finally { controller.abort(); await fs.rm(root, { force: true, recursive: true }); }
});
it("cleans crash leftovers while preserving recent spools and unrelated files", async () => {
const root = await fs.mkdtemp(join(tmpdir(), "anonymous-spool-test-"));
try {