mirror of
https://github.com/tdurieux/anonymous_github.git
synced 2026-10-18 21:57:20 +02:00
200 lines
9.3 KiB
JavaScript
200 lines
9.3 KiB
JavaScript
const { expect } = require("chai");
|
||
const { Readable, PassThrough } = require("stream");
|
||
const { once } = require("events");
|
||
const http = require("node:http");
|
||
const express = require("express");
|
||
const compression = require("compression");
|
||
const { randomBytes } = require("node:crypto");
|
||
const { gunzipSync } = require("node:zlib");
|
||
const archiver = require("archiver");
|
||
const { Parse } = require("unzip-stream");
|
||
require("ts-node/register/transpile-only");
|
||
const got = require("got");
|
||
const GitHubDownload = require("../src/core/source/GitHubDownload").default;
|
||
const config = require("../src/config").default;
|
||
const { AnonymizeTransformer, ContentAnonimizer } = require("../src/core/anonymize-utils");
|
||
const { streamAnonymizedZip } = require("../src/core/zipStream");
|
||
|
||
function notebook() {
|
||
return JSON.stringify({ nbformat: 4, nbformat_minor: 0, metadata: {}, cells: [{
|
||
cell_type: "code", metadata: {}, execution_count: 1,
|
||
source: ["# Alice's experiment"],
|
||
outputs: [{ output_type: "display_data", metadata: {}, data: {
|
||
"image/png": "AbCd".repeat(12 * 1024 * 1024),
|
||
"text/plain": ["Alice completed the experiment"],
|
||
} }],
|
||
}] });
|
||
}
|
||
|
||
async function collect(stream) {
|
||
const chunks = [];
|
||
for await (const chunk of stream) chunks.push(chunk);
|
||
return Buffer.concat(chunks);
|
||
}
|
||
|
||
async function zip(entries) {
|
||
const archive = archiver("zip");
|
||
const result = collect(archive);
|
||
for (const [name, content] of Object.entries(entries)) archive.append(content, { name });
|
||
await archive.finalize();
|
||
return result;
|
||
}
|
||
|
||
async function unzip(buffer) {
|
||
const parser = Parse();
|
||
const entries = {};
|
||
const pending = [];
|
||
parser.on("entry", entry => pending.push(collect(entry).then(data => { entries[entry.path] = data; })));
|
||
const done = once(parser, "finish");
|
||
Readable.from([buffer]).pipe(parser);
|
||
await done;
|
||
await Promise.all(pending);
|
||
return entries;
|
||
}
|
||
|
||
describe("large notebook downloads", function () {
|
||
this.timeout(15000);
|
||
|
||
it("redacts names before and after large outputs within the anonymization deadline", async function () {
|
||
const input = notebook();
|
||
const transformer = new AnonymizeTransformer({ filePath: "example.ipynb", terms: ["Alice"] });
|
||
const output = collect(transformer);
|
||
Readable.from([Buffer.from(input)]).pipe(transformer);
|
||
const result = JSON.parse((await output).toString());
|
||
expect(result.cells[0].source[0]).to.equal("# XXXX-1's experiment");
|
||
expect(result.cells[0].outputs[0].data["text/plain"][0]).to.equal("XXXX-1 completed the experiment");
|
||
expect(result.cells[0].outputs[0].data["image/png"]).to.equal("AbCd".repeat(12 * 1024 * 1024));
|
||
});
|
||
|
||
it("matches RE2 boundaries and case folding for nearby literal candidates", function () {
|
||
for (const term of ["k", "s", "Alice", "a-a", "Σ", "研究", "@Alice", "😀", "---", "@@", " "]) {
|
||
const input = ["", " ", "é", "_", "😀", "𐐀"].flatMap(left =>
|
||
["", " ", "é", "_", "😀", "𐐀"].map(right => `${left}${term} ${term.toUpperCase()} K ſ${right}`)
|
||
).join(" ") + term.repeat(10);
|
||
const reference = new ContentAnonimizer({ terms: [term] });
|
||
for (const compiled of reference.compiledTerms) delete compiled.literalPrefilter;
|
||
expect(new ContentAnonimizer({ terms: [term] }).anonymize(input)).to.equal(reference.anonymize(input));
|
||
}
|
||
});
|
||
|
||
describe("ZIP streaming", function () {
|
||
let originalStream, originalUrl, originalLimit, upstream;
|
||
beforeEach(function () {
|
||
originalStream = got.stream;
|
||
originalUrl = GitHubDownload.prototype.getZipUrl;
|
||
originalLimit = config.MAX_FILE_SIZE;
|
||
GitHubDownload.prototype.getZipUrl = async () => ({ url: "https://example.invalid/source.zip" });
|
||
got.stream = () => upstream;
|
||
});
|
||
afterEach(async function () {
|
||
got.stream = originalStream;
|
||
GitHubDownload.prototype.getZipUrl = originalUrl;
|
||
config.MAX_FILE_SIZE = originalLimit;
|
||
upstream?.destroy();
|
||
await require("../src/core/cache-coordination").closeCacheRedis();
|
||
});
|
||
const options = {
|
||
repoId: "fixture", organization: "owner", repoName: "repo", commit: "HEAD",
|
||
getToken: () => "test", anonymizerOptions: { filePath: "", terms: ["Alice"] },
|
||
};
|
||
|
||
for (const compressed of [false, true]) {
|
||
it(`finishes ZIP responses through Express compression with gzip ${compressed}`, async function () {
|
||
const text = "Alice Σ 研究\n".repeat(25000), binary = randomBytes(128 * 1024);
|
||
const input = await zip({ "root/empty.txt": "", "root/large.txt": text, "root/image.png": binary, "root/after.txt": "Alice" });
|
||
upstream = Readable.from((async function* () {
|
||
for (let offset = 0; offset < input.length; offset += 1021) {
|
||
yield input.subarray(offset, offset + 1021);
|
||
}
|
||
})());
|
||
const app = express();
|
||
app.use(compression({ threshold: 0, highWaterMark: 1024, filter: () => compressed }));
|
||
const drainEmitters = new Set();
|
||
let drainSubscriptions = 0;
|
||
app.get("/archive", async (_req, res) => {
|
||
const on = res.on;
|
||
res.on = function (event, listener) {
|
||
const emitter = on.call(this, event, listener);
|
||
if (event === "drain") { drainSubscriptions++; drainEmitters.add(emitter); }
|
||
return emitter;
|
||
};
|
||
res.type("application/zip");
|
||
await streamAnonymizedZip(options, res);
|
||
});
|
||
const server = await new Promise(resolve => {
|
||
const listening = http.createServer({ highWaterMark: 1024 }, app).listen(0, "127.0.0.1", () => resolve(listening));
|
||
});
|
||
let client;
|
||
try {
|
||
const response = await new Promise((resolve, reject) => {
|
||
client = http.get(`http://127.0.0.1:${server.address().port}/archive`, { headers: { "Accept-Encoding": "gzip" } }, resolve);
|
||
client.setTimeout(8000, () => client.destroy(new Error("ZIP response stalled")));
|
||
client.on("error", reject);
|
||
});
|
||
const bytes = await collect(response);
|
||
expect(response.statusCode).to.equal(200);
|
||
expect(response.headers["content-encoding"]).to.equal(compressed ? "gzip" : undefined);
|
||
const entries = await unzip(compressed ? gunzipSync(bytes) : bytes);
|
||
expect(Object.keys(entries)).to.have.members(["empty.txt", "large.txt", "image.png", "after.txt"]);
|
||
expect(entries["empty.txt"]).to.have.length(0);
|
||
expect(entries["large.txt"].toString()).to.equal(new ContentAnonimizer(options.anonymizerOptions).anonymize(text));
|
||
expect(entries["image.png"]).to.deep.equal(binary);
|
||
expect(entries["after.txt"].toString()).to.equal("XXXX-1");
|
||
expect(drainSubscriptions).to.be.greaterThan(0);
|
||
for (const emitter of drainEmitters) expect(emitter.listenerCount("drain")).to.equal(0);
|
||
} finally {
|
||
client?.destroy(); server.closeAllConnections();
|
||
await new Promise(resolve => server.close(resolve));
|
||
}
|
||
});
|
||
}
|
||
|
||
it("completes an archive containing a large notebook and subsequent entries", async function () {
|
||
upstream = Readable.from([await zip({ "root/example.ipynb": notebook(), "root/after.txt": "Alice" })]);
|
||
const response = new PassThrough();
|
||
const output = collect(response);
|
||
await streamAnonymizedZip(options, response);
|
||
const entries = await unzip(await output);
|
||
expect(Object.keys(entries)).to.have.members(["example.ipynb", "after.txt"]);
|
||
expect(entries["after.txt"].toString()).to.equal("XXXX-1");
|
||
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;
|
||
const response = new PassThrough();
|
||
response.resume();
|
||
const error = once(response, "error");
|
||
await streamAnonymizedZip(options, response);
|
||
expect((await error)[0].message).to.contain("Text file exceeded");
|
||
expect(response.destroyed).to.equal(true);
|
||
expect(response.writableFinished).to.equal(false);
|
||
expect(upstream.destroyed).to.equal(true);
|
||
});
|
||
|
||
it("stops downloading when the client disconnects", async function () {
|
||
upstream = new PassThrough();
|
||
const response = new PassThrough();
|
||
await streamAnonymizedZip(options, response);
|
||
const closed = once(response, "close");
|
||
response.destroy();
|
||
await closed;
|
||
expect(upstream.destroyed).to.equal(true);
|
||
});
|
||
});
|
||
});
|