From 90f14b19b74253bc21ea0946de121490022d91d6 Mon Sep 17 00:00:00 2001 From: tdurieux Date: Tue, 8 Sep 2026 14:08:48 +0200 Subject: [PATCH] fix: archive legacy repository IDs containing spaces --- src/core/recover-repository-owners.ts | 6 +++++- test/recover-repository-owners.test.js | 8 ++++---- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/core/recover-repository-owners.ts b/src/core/recover-repository-owners.ts index b185227..cff26e3 100644 --- a/src/core/recover-repository-owners.ts +++ b/src/core/recover-repository-owners.ts @@ -100,7 +100,11 @@ export async function recoverRepositoryOwners(db: mongo.Db, options: RecoveryOpt const unchanged = (value: unknown) => value === undefined ? { $exists: false } : { $eq: value, $exists: true }; const original = { _id: row._id, repoId: unchanged(row.repoId), owner: unchanged(row.owner), status: unchanged(row.status), "source.accessToken": unchanged(row.source?.accessToken), accessToken: unchanged(row.accessToken) }; - const safeRepoId = () => typeof row.repoId === "string" && /^[a-zA-Z0-9_.-]+$/.test(row.repoId) && ![".", ".."].includes(row.repoId); + // Legacy IDs can contain spaces. Keep their exact spelling for storage lookup. + // Separators/control characters stay forbidden, as do empty and dot-only IDs. + const safeRepoId = () => typeof row.repoId === "string" && + /^[a-zA-Z0-9_. -]+$/.test(row.repoId) && + !["", ".", ".."].includes(row.repoId.trim()); const cleanup = async () => { if (!safeRepoId()) { fail("unsafe_or_missing_repo_id"); return; } try { diff --git a/test/recover-repository-owners.test.js b/test/recover-repository-owners.test.js index 3971d17..617e9a1 100644 --- a/test/recover-repository-owners.test.js +++ b/test/recover-repository-owners.test.js @@ -176,7 +176,7 @@ describe("archive ownerless repositories", () => { expect(f.writes).to.have.length(0); }); it("rejects unsafe storage paths before changing the record", async () => { - for (const repoId of [undefined, "", ".", "..", "../other", "a/b", "/etc"]) { + for (const repoId of [undefined, "", " ", ".", "..", ".. ", "../other", "a/b", "a\\b", "/etc", "repo\n", "repo\0"]) { const f = fixture([{ _id: "repo", repoId }]); await recoverRepositoryOwners(f.db, { ...f.options, apply: true, archiveAllOwnerless: true }); expect(f.events[0].issue).to.equal("unsafe_or_missing_repo_id"); @@ -255,14 +255,14 @@ describe("archived repository access", () => { const before = config.FOLDER; try { config.FOLDER = root; - for (const id of ["archive-target", "keep-sibling"]) { + for (const id of ["Paccmann Polymer", "keep-sibling"]) { fs.mkdirSync(path.join(root, id, "original"), { recursive: true }); fs.writeFileSync(path.join(root, id, "original", "file.txt"), "cached content"); } - const f = fixture([{ _id: "repo", repoId: "archive-target" }]); + const f = fixture([{ _id: "repo", repoId: "Paccmann Polymer" }]); await recoverRepositoryOwners(f.db, { ...f.options, apply: true, archiveAllOwnerless: true, deleteCache: id => new FileSystem().rm(id) }); - expect(fs.existsSync(path.join(root, "archive-target", "original"))).to.equal(false); + expect(fs.existsSync(path.join(root, "Paccmann Polymer", "original"))).to.equal(false); expect(fs.existsSync(path.join(root, "keep-sibling", "original", "file.txt"))).to.equal(true); expect(f.writes).to.have.length(2); } finally { config.FOLDER = before; fs.rmSync(root, { recursive: true, force: true }); }