diff --git a/packages/loopover-miner/lib/contribution-profile-cache.ts b/packages/loopover-miner/lib/contribution-profile-cache.ts index 46f00660c1..ce031b4e20 100644 --- a/packages/loopover-miner/lib/contribution-profile-cache.ts +++ b/packages/loopover-miner/lib/contribution-profile-cache.ts @@ -14,6 +14,7 @@ import { resolveLocalStoreDbPath, } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { isValidRepoSegment } from "./repo-clone.js"; import { CONTRIBUTION_PROFILE_CACHE_PURGE_SPEC, purgeStoreByRepo, @@ -58,6 +59,9 @@ function normalizeRepoFullName(repoFullName: unknown): string { const [owner, repo, extra] = repoFullName.trim().split("/"); if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + // #7795: reject `.`/`..`/control-char segments (via repo-clone.js's shared guard) before this value backs a + // SQLite key or is echoed through a CLI -- the same path-safety check #5831/#7525 rolled out to every sibling. + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); return `${owner}/${repo}`; } diff --git a/packages/loopover-miner/lib/prediction-ledger.ts b/packages/loopover-miner/lib/prediction-ledger.ts index ad4f4bda80..29c7cbf8ba 100644 --- a/packages/loopover-miner/lib/prediction-ledger.ts +++ b/packages/loopover-miner/lib/prediction-ledger.ts @@ -1,6 +1,7 @@ import type { DatabaseSync } from "node:sqlite"; import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { isValidRepoSegment } from "./repo-clone.js"; import { PREDICTION_LEDGER_PURGE_SPEC, PREDICTION_LEDGER_RETENTION_SPEC, @@ -87,6 +88,9 @@ function normalizeRepoFullName(repoFullName: string): string { if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); const [owner, repo, extra] = repoFullName.trim().split("/"); if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + // #7795: reject `.`/`..`/control-char segments (via repo-clone.js's shared guard) before this value backs a + // SQLite key or is echoed through a CLI -- the same path-safety check #5831/#7525 rolled out to every sibling. + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); return `${owner}/${repo}`; } diff --git a/packages/loopover-miner/lib/replay-snapshot.ts b/packages/loopover-miner/lib/replay-snapshot.ts index 363e9798f4..53f89bf3a3 100644 --- a/packages/loopover-miner/lib/replay-snapshot.ts +++ b/packages/loopover-miner/lib/replay-snapshot.ts @@ -2,6 +2,7 @@ import { join } from "node:path"; import { removeWorktree } from "@loopover/engine"; import type { WorktreeExecFn, WorktreeRemoveResult } from "@loopover/engine"; import { openLocalStoreAdapter, resolveLocalStoreDbPath, normalizeLocalStoreDbPath } from "./local-store.js"; +import { isValidRepoSegment } from "./repo-clone.js"; // Freeze/snapshot mechanism for historical replay targets (#3010). Given a repo and a commit SHA T, exports: // (a) the full working tree checked out AT T via a DETACHED git worktree -- the same isolation primitive @@ -72,6 +73,9 @@ function normalizeRepoFullName(repoFullName: string): string { if (typeof repoFullName !== "string") throw new Error("invalid_repo_full_name"); const [owner, repo, extra] = repoFullName.trim().split("/"); if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + // #7795: reject `.`/`..`/control-char segments (via repo-clone.js's shared guard) before this value backs a + // SQLite key or is echoed through a CLI -- the same path-safety check #5831/#7525 rolled out to every sibling. + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); return `${owner}/${repo}`; } diff --git a/packages/loopover-miner/lib/run-state.ts b/packages/loopover-miner/lib/run-state.ts index ec8de4d4a4..70712b92ce 100644 --- a/packages/loopover-miner/lib/run-state.ts +++ b/packages/loopover-miner/lib/run-state.ts @@ -2,6 +2,7 @@ import type { DatabaseSync } from "node:sqlite"; import { DEFAULT_FORGE_CONFIG } from "./forge-config.js"; import { normalizeLocalStoreDbPath, openLocalStoreAdapter, resolveLocalStoreDbPath } from "./local-store.js"; import { applySchemaMigrations } from "./schema-version.js"; +import { isValidRepoSegment } from "./repo-clone.js"; import { RUN_STATE_PURGE_SPEC, purgeStoreByRepo } from "./store-maintenance.js"; export type RunState = "idle" | "discovering" | "planning" | "preparing"; @@ -57,6 +58,9 @@ function normalizeRepoFullName(repoFullName: string): string { const trimmed = repoFullName.trim(); const [owner, repo, extra] = trimmed.split("/"); if (!owner || !repo || extra !== undefined) throw new Error("invalid_repo_full_name"); + // #7795: reject `.`/`..`/control-char segments (via repo-clone.js's shared guard) before this value backs a + // SQLite key or is echoed through a CLI -- the same path-safety check #5831/#7525 rolled out to every sibling. + if (!isValidRepoSegment(owner) || !isValidRepoSegment(repo)) throw new Error("invalid_repo_full_name"); return `${owner}/${repo}`; } diff --git a/test/unit/miner-contribution-profile-cache.test.ts b/test/unit/miner-contribution-profile-cache.test.ts index 621b23c536..ae55b9f48d 100644 --- a/test/unit/miner-contribution-profile-cache.test.ts +++ b/test/unit/miner-contribution-profile-cache.test.ts @@ -158,6 +158,9 @@ describe("contribution-profile cache store (#6797)", () => { expect(() => store.put({ repoFullName: 42 } as never)).toThrow( "invalid_repo_full_name", ); + // #7795: `.`/`..`/control-char segments must be rejected too (both owner and repo), not just missing/extra slashes. + expect(() => store.get("../etc")).toThrow("invalid_repo_full_name"); + expect(() => store.get("owner/..")).toThrow("invalid_repo_full_name"); }); it("exposes module-level get/put helpers backed by the default DB path", () => { diff --git a/test/unit/miner-prediction-ledger.test.ts b/test/unit/miner-prediction-ledger.test.ts index 404ae0d385..982994adfc 100644 --- a/test/unit/miner-prediction-ledger.test.ts +++ b/test/unit/miner-prediction-ledger.test.ts @@ -68,6 +68,9 @@ describe("miner prediction ledger (#4263)", () => { it("rejects invalid inputs field by field", () => { const ledger = tempLedger(); expect(() => ledger.appendPrediction({ ...VALID, repoFullName: "no-slash" })).toThrow(/invalid_repo_full_name/); + // #7795: `.`/`..`/control-char segments must be rejected too (both owner and repo), not just missing/extra slashes. + expect(() => ledger.appendPrediction({ ...VALID, repoFullName: "../etc" })).toThrow(/invalid_repo_full_name/); + expect(() => ledger.appendPrediction({ ...VALID, repoFullName: "owner/.." })).toThrow(/invalid_repo_full_name/); expect(() => ledger.appendPrediction({ ...VALID, targetId: 0 })).toThrow(/invalid_target_id/); expect(() => ledger.appendPrediction({ ...VALID, conclusion: "" })).toThrow(/invalid_conclusion/); expect(() => ledger.appendPrediction({ ...VALID, engineVersion: "" })).toThrow(/invalid_engine_version/); diff --git a/test/unit/miner-replay-snapshot.test.ts b/test/unit/miner-replay-snapshot.test.ts index 31a0c1cf11..97e709bfbf 100644 --- a/test/unit/miner-replay-snapshot.test.ts +++ b/test/unit/miner-replay-snapshot.test.ts @@ -360,6 +360,9 @@ describe("exportReplaySnapshot (#3010)", () => { await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "noslash", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "a/b/c", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); + // #7795: `.`/`..`/control-char segments must be rejected too (both owner and repo), not just missing/extra slashes. + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "../etc", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); + await expect(exportReplaySnapshot({ repoPath: "/repo", repoFullName: "owner/..", commitSha: "a" }, deps)).rejects.toThrow("invalid_repo_full_name"); }); it("assertExecResult falls back to a generic exit-code message when stderr is entirely absent", async () => { diff --git a/test/unit/miner-run-state.test.ts b/test/unit/miner-run-state.test.ts index cbbe71c202..965d3b7d7d 100644 --- a/test/unit/miner-run-state.test.ts +++ b/test/unit/miner-run-state.test.ts @@ -129,6 +129,9 @@ describe("loopover-miner run-state store (#2289)", () => { try { expect(() => store.getRunState("not-a-full-name")).toThrow("invalid_repo_full_name"); expect(() => store.setRunState("owner/repo/extra", "idle")).toThrow("invalid_repo_full_name"); + // #7795: `.`/`..`/control-char segments must be rejected too (both owner and repo), not just missing/extra slashes. + expect(() => store.getRunState("../etc")).toThrow("invalid_repo_full_name"); + expect(() => store.setRunState("owner/..", "idle")).toThrow("invalid_repo_full_name"); expect(() => store.setRunState("owner/repo", "blocked" as never)).toThrow("invalid_run_state"); expect(store.getRunState("owner/repo")).toBeNull(); } finally {