diff --git a/src/application/ReferenceSourceGitDirectory.ts b/src/application/ReferenceSourceGitDirectory.ts index a2543d51cf..0cb9257cc8 100644 --- a/src/application/ReferenceSourceGitDirectory.ts +++ b/src/application/ReferenceSourceGitDirectory.ts @@ -41,20 +41,20 @@ export const locateReferenceSourceGitDirectory = async ( // Derived joins and the Git library normalize paths lexically. Admit the // physical directory first, while the source pointer stays in its file. const gitDirectory = await realpath(selectedGitDirectory); - let commonDirectory = gitDirectory; + let common: string | undefined; try { - const common = (await readText(join(gitDirectory, "commondir"))).replace( + common = (await readText(join(gitDirectory, "commondir"))).replace( /\r?\n$/u, "", ); - if (common.length === 0) - throw new Error("Invalid Git common-directory pointer"); - commonDirectory = await realpath( - absoluteCallerPath(common, { cwd: gitDirectory }), - ); } catch (cause: unknown) { if (!hasCode(cause, "ENOENT")) throw cause; } + if (common === "") throw new Error("Invalid Git common-directory pointer"); + const commonDirectory = + common === undefined + ? gitDirectory + : await realpath(absoluteCallerPath(common, { cwd: gitDirectory })); return { repository, gitDirectory, commonDirectory }; } }; diff --git a/src/reference/ReferenceSourceReader.ts b/src/reference/ReferenceSourceReader.ts index ab3afc3a76..3d6577e4d1 100644 --- a/src/reference/ReferenceSourceReader.ts +++ b/src/reference/ReferenceSourceReader.ts @@ -91,6 +91,13 @@ const readSource = async ( bytesRead: state.bytesRead, limitations: [PATH_RACE_LIMITATION], }; + if (traversal.ok && state.rootFailure?.code === "io") + return err({ + tag: "reference-source-reader", + code: "io", + message: state.rootFailure.message, + partial: read, + }); return traversal.ok ? ok(read) : err({ ...traversal.error, partial: read }); }; diff --git a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts index 565ddf9088..e9311d574b 100644 --- a/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts +++ b/tests/boundary/filesystem/referenceSourceIgnorePolicy.test.ts @@ -155,3 +155,20 @@ it.skipIf(process.platform === "win32")( expect(graph.vcs).toMatchObject({ kind: "git", head: intendedHead }); }, ); + +it("keeps a present but unresolved Git common-directory pointer from selecting private decoy configuration", async () => { + const root = await createTestTempDirectory("rea-reference-common-missing-"); + await mkdir(join(root, ".git")); + await writeFile(join(root, ".git", "HEAD"), `${"1".repeat(40)}\n`); + await writeFile(join(root, ".git", "config"), "[core]\nignorecase = true\n"); + await writeFile(join(root, ".git", "commondir"), "../missing-common\n"); + const result = await importReferenceSource({ + root, + caller: "ignore-policy-test", + policy: { secretPatterns: [] }, + }); + expect(result).toMatchObject({ + ok: false, + error: { code: "io", message: expect.stringContaining("missing-common") }, + }); +}); diff --git a/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts b/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts index cc5b689e49..8747c81d8e 100644 --- a/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts +++ b/tests/boundary/filesystem/referenceSourceUnreadableDirectories.test.ts @@ -20,6 +20,33 @@ const permissionChecksUnavailable = process.platform === "win32" || process.getuid?.() === 0; describe("reference source native unreadable directories", () => { + it.skipIf(permissionChecksUnavailable)( + "retains a root enumeration IO failure as an error and recovers after permission repair", + async () => { + const root = await createTestTempDirectory("rea-source-unreadable-root-"); + await writeFile(join(root, "main.txt"), "root bytes"); + await chmod(root, 0o100); + try { + const result = await importTree(root); + expect(result).toMatchObject({ + ok: false, + error: { + code: "io", + message: expect.stringContaining("EACCES"), + partial: { rootFailure: { code: "io" } }, + }, + }); + if (!result.ok) expect(result.error.message).toContain(root); + } finally { + await chmod(root, 0o700); + } + const recovered = await importTree(root); + expect(recovered.ok && recovered.value.entries).toContainEqual( + expect.objectContaining({ path: "main.txt", content_state: "hashed" }), + ); + }, + ); + it.skipIf(permissionChecksUnavailable).each(["blocked", "src/blocked"])( "preserves a partial graph when %s cannot be enumerated", async (blocked) => {