Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 14 additions & 5 deletions src/selfhost/private-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,11 +16,18 @@
// per-repo file fully REPLACES the global fallback — "fallback" means "used only when no per-repo file exists",
// not a deep merge). The slug is lowercased (GitHub repo full-names are case-insensitive; #1390 already lowercased).
import { readFile } from "node:fs/promises";
import { join } from "node:path";
import { join, resolve } from "node:path";
import type { RepoFocusManifestFetcher } from "../signals/focus-manifest-loader";

/** The bare config filenames tried inside a per-repo folder and at the dir root (global fallback), in priority order. */
const CONFIG_BASENAMES = [".gittensory.yml", ".gittensory.yaml", ".gittensory.json"] as const;
const GITHUB_OWNER_SEGMENT = /^[a-z0-9](?:[a-z0-9-]{0,37}[a-z0-9])?$/;
const GITHUB_REPO_SEGMENT = /^[a-z0-9._-]+$/;

function isSafeRepoSegment(segment: string): boolean {
return segment !== "." && segment !== ".." && GITHUB_REPO_SEGMENT.test(segment);
}

/** Global-fallback candidates (relative to GITTENSORY_REPO_CONFIG_DIR): the dir-root `.gittensory.{yml,yaml,json}`
* applied to any repo without its own per-repo file. */
export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES];
Expand All @@ -31,9 +38,10 @@ export const GLOBAL_CONFIG_CANDIDATES: string[] = [...CONFIG_BASENAMES];
* the lowercased repo name. An invalid repo full name (no single interior slash) yields no candidates. */
export function localConfigCandidates(repoFullName: string): string[] {
const slash = repoFullName.indexOf("/");
if (slash <= 0 || slash === repoFullName.length - 1) return [];
if (slash <= 0 || slash === repoFullName.length - 1 || slash !== repoFullName.lastIndexOf("/")) return [];
const owner = repoFullName.slice(0, slash).toLowerCase();
const repo = repoFullName.slice(slash + 1).toLowerCase();
if (!GITHUB_OWNER_SEGMENT.test(owner) || !isSafeRepoSegment(repo)) return [];
const slug = `${owner}__${repo}`;
return [
// 1. owner-qualified folder — `{owner}__{repo}/.gittensory.{yml,yaml,json}`
Expand All @@ -52,14 +60,15 @@ export function localConfigCandidates(repoFullName: string): string[] {
* candidates and is NOT served the global fallback (it is never a real webhook repo). A read error on one
* candidate is swallowed so the next candidate is tried. */
export function makeLocalManifestReader(dir: string | undefined): RepoFocusManifestFetcher | null {
const base = (dir ?? "").trim();
if (!base) return null;
const trimmed = (dir ?? "").trim();
if (!trimmed) return null;
const base = resolve(trimmed);
return async (repoFullName: string): Promise<string | null> => {
const perRepo = localConfigCandidates(repoFullName);
if (perRepo.length === 0) return null; // invalid repo name → no per-repo file AND no global fallback
for (const candidate of [...perRepo, ...GLOBAL_CONFIG_CANDIDATES]) {
try {
return await readFile(join(base, candidate), "utf8");
return await readFile(resolve(base, candidate), "utf8");
} catch {
// ENOENT / unreadable → try the next candidate
}
Expand Down
15 changes: 14 additions & 1 deletion test/unit/private-config.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { dirname, join } from "node:path";
import { describe, expect, it } from "vitest";
import { GLOBAL_CONFIG_CANDIDATES, localConfigCandidates, makeLocalManifestReader } from "../../src/selfhost/private-config";

Expand All @@ -25,6 +25,12 @@ describe("localConfigCandidates (container-private config paths)", () => {
expect(localConfigCandidates("no-slash")).toEqual([]); // slash < 0 → slash <= 0
expect(localConfigCandidates("/leading")).toEqual([]); // slash at 0 → slash <= 0
expect(localConfigCandidates("trailing/")).toEqual([]); // slash at len-1
expect(localConfigCandidates("owner/repo/extra")).toEqual([]); // more than one slash
expect(localConfigCandidates("owner/..")).toEqual([]);
expect(localConfigCandidates("owner/.")).toEqual([]);
expect(localConfigCandidates("owner/repo name")).toEqual([]);
expect(localConfigCandidates("bad_owner/repo")).toEqual([]);
expect(localConfigCandidates("-owner/repo")).toEqual([]);
});
it("exposes the dir-root global-fallback candidates", () => {
expect(GLOBAL_CONFIG_CANDIDATES).toEqual([".gittensory.yml", ".gittensory.yaml", ".gittensory.json"]);
Expand Down Expand Up @@ -90,4 +96,11 @@ describe("makeLocalManifestReader (GITTENSORY_REPO_CONFIG_DIR)", () => {
const reader = makeLocalManifestReader(dir);
expect(await reader!("no-slash")).toBeNull(); // perRepo.length === 0 early return
});

it("rejects traversal repo names instead of reading outside the private config directory", async () => {
const dir = mkdtempSync(join(tmpdir(), "gt-repo-config-"));
writeFileSync(join(dirname(dir), ".gittensory.yml"), "gate:\n enabled: true\n");
const reader = makeLocalManifestReader(dir);
expect(await reader!("owner/..")).toBeNull();
});
});
Loading